feat: add stratum-orchestrator with graph, state, NATS, and Nickel action nodes
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled

New crates: stratum-orchestrator (Cedar authz, Vault secrets, Nu/agent executors,
  saga runner), stratum-graph (petgraph DAG + SurrealDB repo), stratum-state
  (SurrealDB tracker), platform-nats (NKey auth client), ncl-import-resolver.

  Updates: stratum-embeddings (SurrealDB store + persistent cache), stratum-llm
  circuit breaker. Adds Nickel action-nodes, schemas, config, Nushell scripts,
  docker-compose dev stack, and ADR-003.
This commit is contained in:
Jesús Pérez 2026-02-22 21:33:26 +00:00
parent f129b33ed7
commit 9095ea6d8e
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
90 changed files with 13171 additions and 2227 deletions

View File

@ -37,7 +37,6 @@ debug = true
debug-assertions = true
overflow-checks = true
lto = false
panic = "unwind"
incremental = true
[profile.bench]
@ -48,12 +47,8 @@ debug-assertions = false
overflow-checks = false
lto = "thin"
codegen-units = 1
panic = "abort"
incremental = false
# Resolver version
resolver = "2"
[term]
# Terminal colors
color = "auto"

4395
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -23,14 +23,13 @@ humantime-serde = "1.1"
# Caching
moka = { version = "0.12", features = ["future"] }
sled = "0.34"
# Embeddings
fastembed = "5.8"
fastembed = "5.11"
# Vector storage
lancedb = "0.23"
surrealdb = { version = "2.6", features = ["kv-mem"] }
lancedb = "0.26"
surrealdb = { version = "3", features = ["kv-mem"] }
# LOCKED: Arrow 56.x required for LanceDB 0.23 compatibility
# LanceDB 0.23 uses Arrow 56.2.0 internally - Arrow 57 breaks API compatibility
# DO NOT upgrade to Arrow 57 until LanceDB supports it
@ -51,10 +50,23 @@ prometheus = "0.14"
xxhash-rust = { version = "0.8", features = ["xxh3"] }
dirs = "6.0"
chrono = "0.4"
uuid = "1.20"
uuid = { version = "1.21", features = ["v4"] }
which = "8.0"
bytes = "1.11"
# Orchestration
async-nats = "0.46"
dashmap = "6.1"
notify = { version = "8.2", default-features = false, features = ["macos_fsevent"] }
cedar-policy = "4.9"
nkeys = "0.4"
regex = "1.12"
tokio-util = { version = "0.7", features = ["rt"] }
tokio-stream = "0.1"
axum = { version = "0.8", features = ["json"] }
tower-http = { version = "0.6", features = ["trace"] }
# Testing
tokio-test = "0.4"
approx = "0.5"
tempfile = "3.24"
tempfile = "3.25"

View File

@ -0,0 +1,15 @@
let base = import "../nickel/stratum-base/stratum-base.ncl" in
{
id = "build-crate",
handler = "scripts/nu/build.nu",
input_schemas = {
"linted-code" = "schemas/capabilities/linted-code.ncl",
"formatted-code" = "schemas/capabilities/formatted-code.ncl",
},
output_schemas = { "built-artifact" = "schemas/capabilities/built-artifact.ncl" },
compensate = "scripts/nu/build-rollback.nu",
retry = { max = 3, backoff_secs = 10, strategy = 'exponential },
timeout_secs = 600,
atomic = true,
triggers = [],
} | base.NodeDefinition

View File

@ -0,0 +1,12 @@
let base = import "../nickel/stratum-base/stratum-base.ncl" in
{
id = "fmt-crate",
handler = "scripts/nu/fmt.nu",
input_schemas = {},
output_schemas = { "formatted-code" = "schemas/capabilities/formatted-code.ncl" },
compensate = null,
retry = { max = 1, backoff_secs = 5, strategy = 'fixed },
timeout_secs = 60,
atomic = true,
triggers = ["dev.crate.*.modified"],
} | base.NodeDefinition

View File

@ -0,0 +1,12 @@
let base = import "../nickel/stratum-base/stratum-base.ncl" in
{
id = "install-crate",
handler = "scripts/nu/install.nu",
input_schemas = { "built-artifact" = "schemas/capabilities/built-artifact.ncl" },
output_schemas = { "installed" = "schemas/capabilities/installed.ncl" },
compensate = "scripts/nu/install-rollback.nu",
retry = { max = 2, backoff_secs = 5, strategy = 'linear },
timeout_secs = 180,
atomic = true,
triggers = [],
} | base.NodeDefinition

View File

@ -0,0 +1,12 @@
let base = import "../nickel/stratum-base/stratum-base.ncl" in
{
id = "lint-crate",
handler = "scripts/nu/lint.nu",
input_schemas = {},
output_schemas = { "linted-code" = "schemas/capabilities/linted-code.ncl" },
compensate = null,
retry = { max = 2, backoff_secs = 5, strategy = 'fixed },
timeout_secs = 120,
atomic = true,
triggers = ["dev.crate.*.modified"],
} | base.NodeDefinition

View File

@ -0,0 +1,12 @@
let base = import "../nickel/stratum-base/stratum-base.ncl" in
{
id = "notify-result",
handler = "scripts/nu/notify.nu",
input_schemas = { "installed" = "schemas/capabilities/installed.ncl" },
output_schemas = {},
compensate = null,
retry = { max = 3, backoff_secs = 2, strategy = 'exponential },
timeout_secs = 30,
atomic = false,
triggers = [],
} | base.NodeDefinition

View File

@ -0,0 +1,477 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1170 830">
<defs>
<style>
.flow { animation: flow-a 3s linear infinite; }
.flow-fast { animation: flow-a 1.8s linear infinite; }
.flow-slow { animation: flow-a 5s linear infinite; }
.flow-vslow { animation: flow-a 7s linear infinite; }
@keyframes flow-a { from { stroke-dashoffset: 24; } to { stroke-dashoffset: 0; } }
.orbit { animation: orbit-a 8s linear infinite; }
@keyframes orbit-a { from { stroke-dashoffset: 0; } to { stroke-dashoffset: -68; } }
.glow-pulse { animation: glow-a 3s ease-in-out infinite; }
@keyframes glow-a { 0%,100% { opacity:.08; } 50% { opacity:.28; } }
.hb { animation: hb-a 3s ease-in-out infinite; }
.hb-d1 { animation-delay: .4s; }
.hb-d2 { animation-delay: .8s; }
.hb-d3 { animation-delay:1.2s; }
.hb-d4 { animation-delay:1.6s; }
.hb-d5 { animation-delay:2.0s; }
@keyframes hb-a { 0%,100% { opacity:.85; } 50% { opacity:1; } }
.particle-spin1 { animation: spin1 6s linear infinite; }
.particle-spin2 { animation: spin2 9s linear infinite; }
.particle-spin3 { animation: spin3 14s linear infinite; }
@keyframes spin1 { from { transform: rotate(0deg) translateX(120px); } to { transform: rotate(360deg) translateX(120px); } }
@keyframes spin2 { from { transform: rotate(120deg) translateX(120px); } to { transform: rotate(480deg) translateX(120px); } }
@keyframes spin3 { from { transform: rotate(240deg) translateX(120px); } to { transform: rotate(600deg) translateX(120px); } }
</style>
<radialGradient id="bg-grad" cx="50%" cy="42%" r="58%">
<stop offset="0%" stop-color="#131929"/>
<stop offset="100%" stop-color="#0F172A"/>
</radialGradient>
<linearGradient id="title-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="50%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#1e1b4b"/>
</linearGradient>
<linearGradient id="orch-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="100%" stop-color="#0d0b22"/>
</linearGradient>
<linearGradient id="auth-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1c1700"/>
<stop offset="100%" stop-color="#0f0e00"/>
</linearGradient>
<linearGradient id="surreal-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1a0f2e"/>
<stop offset="100%" stop-color="#100820"/>
</linearGradient>
<linearGradient id="vault-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1c1400"/>
<stop offset="100%" stop-color="#110d00"/>
</linearGradient>
<linearGradient id="oci-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#021f20"/>
<stop offset="100%" stop-color="#011314"/>
</linearGradient>
<linearGradient id="forgejo-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e0f02"/>
<stop offset="100%" stop-color="#120900"/>
</linearGradient>
<linearGradient id="exec-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#021a0b"/>
<stop offset="100%" stop-color="#011007"/>
</linearGradient>
<linearGradient id="agent-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#12082a"/>
<stop offset="100%" stop-color="#0a0518"/>
</linearGradient>
<linearGradient id="ncl-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#6366F1" stop-opacity=".25"/>
<stop offset="50%" stop-color="#22D3EE" stop-opacity=".15"/>
<stop offset="100%" stop-color="#6366F1" stop-opacity=".25"/>
</linearGradient>
<linearGradient id="event-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e0a00"/>
<stop offset="100%" stop-color="#110600"/>
</linearGradient>
<linearGradient id="mod-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1a1850"/>
<stop offset="100%" stop-color="#10102e"/>
</linearGradient>
<linearGradient id="mod-ag-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1E1060"/>
<stop offset="100%" stop-color="#140C48"/>
</linearGradient>
<linearGradient id="mod-pc-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#082030"/>
<stop offset="100%" stop-color="#041520"/>
</linearGradient>
<linearGradient id="mod-sr-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0A2010"/>
<stop offset="100%" stop-color="#061508"/>
</linearGradient>
<linearGradient id="mod-re-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#201800"/>
<stop offset="100%" stop-color="#140F00"/>
</linearGradient>
<linearGradient id="orch-stripe" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#6366F1"/>
<stop offset="100%" stop-color="#22D3EE"/>
</linearGradient>
<filter id="shadow" x="-15%" y="-15%" width="130%" height="130%">
<feDropShadow dx="0" dy="3" stdDeviation="8" flood-color="#000" flood-opacity=".5"/>
</filter>
<filter id="glow-c" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<marker id="arr-cyan" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#22D3EE"/></marker>
<marker id="arr-gold" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#F59E0B"/></marker>
<marker id="arr-green" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#10B981"/></marker>
<marker id="arr-orange" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#F97316"/></marker>
<marker id="arr-purple" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#A78BFA"/></marker>
<marker id="arr-indigo" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#818CF8"/></marker>
<marker id="arr-silver" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#64748B"/></marker>
<marker id="arr-dcyan" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#06B6D4"/></marker>
<!-- ─── stratumiops-h logo defs ─── -->
<linearGradient id="hpLayerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#4F46E5"/>
</linearGradient>
<linearGradient id="hpProcessorGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#06B6D4"/>
</linearGradient>
<linearGradient id="hpFlowGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="0%;100%;0%" dur="2.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="100%;200%;100%" dur="2.5s" repeatCount="indefinite"/>
</linearGradient>
<linearGradient id="hpUnderlineGrad" x1="280" y1="195" x2="750" y2="195" gradientUnits="userSpaceOnUse">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
</linearGradient>
<linearGradient id="hpShimmer" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="40%" style="stop-color:#6366F1"/>
<stop offset="50%" style="stop-color:#22D3EE"/><stop offset="60%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="-100%;100%" dur="3s" repeatCount="indefinite" begin="2s"/>
<animate attributeName="x2" values="0%;200%" dur="3s" repeatCount="indefinite" begin="2s"/>
</linearGradient>
<filter id="hpProcessorGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="5" result="coloredBlur"><animate attributeName="stdDeviation" values="4;8;4" dur="1.5s" repeatCount="indefinite"/></feGaussianBlur>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="hpNodeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<clipPath id="hpTextReveal">
<rect x="280" y="80" width="0" height="150">
<animate attributeName="width" values="0;800" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
</clipPath>
<path id="hpPathIn1" d="M25 40 Q25 65 55 80 Q85 95 95 100" fill="none"/>
<path id="hpPathIn2" d="M215 40 Q215 65 185 80 Q155 95 145 100" fill="none"/>
<path id="hpPathOut1" d="M95 100 Q85 105 55 120 Q25 135 25 160" fill="none"/>
<path id="hpPathOut2" d="M145 100 Q155 105 185 120 Q215 135 215 160" fill="none"/>
</defs>
<!-- ─── BACKGROUND ─── -->
<rect width="1170" height="830" fill="url(#bg-grad)"/>
<g opacity=".025">
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M40 0L0 0 0 40" fill="none" stroke="#fff" stroke-width=".5"/>
</pattern>
<rect width="1170" height="830" fill="url(#grid)"/>
</g>
<g opacity=".3">
<circle cx="90" cy="200" r=".8" fill="#fff"/>
<circle cx="420" cy="88" r=".6" fill="#fff"/>
<circle cx="980" cy="130" r="1" fill="#fff"/>
<circle cx="55" cy="600" r=".5" fill="#fff"/>
<circle cx="700" cy="800" r=".6" fill="#fff"/>
</g>
<!-- ─── TITLE BAR ─── -->
<rect x="0" y="0" width="1170" height="60" fill="url(#title-grad)"/>
<line x1="0" y1="60" x2="1170" y2="60" stroke="#6366F1" stroke-width=".8" opacity=".5"/>
<text x="600" y="30" text-anchor="middle" fill="#22D3EE" font-family="'IBM Plex Mono',monospace" font-size="19" font-weight="700" letter-spacing="3" dominant-baseline="middle">STRATUM ORCHESTRATOR</text>
<text x="600" y="50" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="9" letter-spacing="1.5">Event-Driven · Graph-Guided · Atomic Execution · Stateless · OCI-Native</text>
<!-- ─── EVENT SOURCES ROW ─── -->
<text x="400" y="82" text-anchor="middle" fill="#F97316" font-family="'IBM Plex Mono',monospace" font-size="8" letter-spacing="2" opacity=".8">EVENT SOURCES</text>
<rect x="55" y="90" width="128" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb"/>
<rect x="55" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="55" y="90" width="128" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".4"/>
<text x="70" y="111" fill="#fff" font-family="Inter,sans-serif" font-size="11" font-weight="600">provisioning</text>
<text x="70" y="127" fill="#F97316" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".8">emits dev.crate.&gt;</text>
<text x="70" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">crate-modified · deploy</text>
<rect x="200" y="90" width="118" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d1"/>
<rect x="200" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="200" y="90" width="118" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".4"/>
<text x="215" y="111" fill="#fff" font-family="Inter,sans-serif" font-size="11" font-weight="600">kogral</text>
<text x="215" y="127" fill="#F97316" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".8">emits dev.knowledge.&gt;</text>
<text x="215" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">node-updated · indexed</text>
<rect x="335" y="90" width="120" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d2"/>
<rect x="335" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="335" y="90" width="120" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".4"/>
<text x="350" y="111" fill="#fff" font-family="Inter,sans-serif" font-size="11" font-weight="600">syntaxis</text>
<text x="350" y="127" fill="#F97316" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".8">emits dev.project.&gt;</text>
<text x="350" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">phase · task-completed</text>
<rect x="472" y="90" width="135" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d3"/>
<rect x="472" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="472" y="90" width="135" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".4"/>
<text x="487" y="111" fill="#fff" font-family="Inter,sans-serif" font-size="11" font-weight="600">stratumiops</text>
<text x="487" y="127" fill="#F97316" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".8">emits dev.model.&gt;</text>
<text x="487" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">llm-call · embed-request</text>
<rect x="624" y="90" width="118" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d4"/>
<rect x="624" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="624" y="90" width="118" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".4"/>
<text x="639" y="111" fill="#fff" font-family="Inter,sans-serif" font-size="11" font-weight="600">typedialog</text>
<text x="639" y="127" fill="#F97316" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".8">emits dev.form.&gt;</text>
<text x="639" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">submitted · validated</text>
<rect x="755" y="90" width="120" height="60" rx="6" fill="none" stroke="#64748B" stroke-width=".8" stroke-dasharray="4 3" opacity=".5"/>
<text x="815" y="127" text-anchor="middle" fill="#64748B" font-family="Inter,sans-serif" font-size="19">+ more ...</text>
<!-- ─── CONNECTIONS: PROJECTS → NATS (NATS cx=355 cy=385 r=129 → anillo exterior que pulsa) ─── -->
<line x1="119" y1="150" x2="317" y2="262" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="259" y1="150" x2="337" y2="257" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="395" y1="150" x2="355" y2="256" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="539" y1="150" x2="374" y2="258" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="686" y1="150" x2="396" y2="263" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<!-- AUTH LAYER removed: NKeys → badge on NATS→Orch arrow; Cedar → inside RuleEngine -->
<!-- ─── NATS ORBITAL RING (cx=355, cy=385, r×0.9: 129/123/120/66) ─── -->
<circle cx="355" cy="385" r="129" fill="none" stroke="#22D3EE" stroke-width="14" opacity=".04" class="glow-pulse"/>
<circle cx="355" cy="385" r="123" fill="none" stroke="#22D3EE" stroke-width="10" opacity=".06" class="glow-pulse" filter="url(#glow-c)"/>
<circle cx="355" cy="385" r="120" fill="none" stroke="#64748B" stroke-width="1.5" stroke-dasharray="10 7" class="orbit"/>
<circle cx="355" cy="385" r="66" fill="none" stroke="#6366F1" stroke-width=".5" opacity=".2"/>
<radialGradient id="nats-inner" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#22D3EE" stop-opacity=".12"/>
<stop offset="100%" stop-color="#6366F1" stop-opacity=".04"/>
</radialGradient>
<circle cx="355" cy="385" r="66" fill="url(#nats-inner)"/>
<text x="355" y="379" text-anchor="middle" fill="#22D3EE" font-family="'IBM Plex Mono',monospace" font-size="20" font-weight="700" filter="url(#glow-c)">NATS</text>
<text x="355" y="398" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="9">JetStream</text>
<text x="344" y="235" text-anchor="middle" fill="#22D3EE" font-family="'IBM Plex Mono',monospace" font-size="9" opacity=".7">dev.&gt;</text>
<g transform="translate(355,385)">
<circle r="4" fill="#22D3EE" opacity=".9" class="particle-spin1"/>
<circle r="3.5" fill="#6366F1" opacity=".7" class="particle-spin2"/>
<circle r="3" fill="#F97316" opacity=".5" class="particle-spin3"/>
</g>
<!-- ─── NATS → ORCHESTRATOR (orbit right x=475, orch left x=498) — NKey checkpoint badge ─── -->
<path d="M 475,385 L 495,385" stroke="#22D3EE" stroke-width="3" stroke-dasharray="8 4" class="flow-fast" marker-end="url(#arr-cyan)" filter="url(#glow-c)"/>
<circle r="4" fill="#22D3EE" opacity=".85">
<animateMotion dur="0.25s" repeatCount="indefinite" path="M 475,385 L 495,385"/>
</circle>
<circle r="3" fill="#6366F1" opacity=".7">
<animateMotion dur="0.25s" repeatCount="indefinite" begin="0.08s" path="M 475,385 L 495,385"/>
</circle>
<!-- NKey verify badge — checkpoint at connection boundary -->
<rect x="379" y="419" width="78" height="22" rx="3" fill="#1c1700" stroke="#F59E0B" stroke-width=".7" opacity=".9"/>
<text x="418" y="429" text-anchor="middle" fill="#F59E0B" font-family="'IBM Plex Mono',monospace" font-size="7">NKey verify</text>
<text x="418" y="439" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="6.5">ed25519 JWT</text>
<!-- ─── ORCHESTRATOR BOX (x=498, y=250, w=375, h=300, center x=685) ─── -->
<rect x="498" y="250" width="375" height="300" rx="10" fill="#000" opacity=".2" transform="translate(3,5)"/>
<rect x="498" y="250" width="375" height="300" rx="10" fill="url(#orch-grad)" filter="url(#shadow)" class="hb"/>
<rect x="498" y="250" width="375" height="300" rx="10" fill="none" stroke="#6366F1" stroke-width=".8" opacity=".5" class="glow-pulse"/>
<rect x="498" y="250" width="4" height="300" rx="2" fill="url(#orch-stripe)"/>
<text x="685" y="273" text-anchor="middle" fill="#fff" font-family="Inter,sans-serif" font-size="14" font-weight="700">STRATUM ORCHESTRATOR</text>
<text x="685" y="290" text-anchor="middle" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Agnostic · Stateless · Graph-Guided</text>
<!-- Internal modules 2×2 (cols of 170px each, gap 8px) -->
<!-- ActionGraph -->
<rect x="510" y="305" width="170" height="72" rx="6" fill="url(#mod-ag-grad)" stroke="#4F46E5" stroke-width=".7" opacity=".9"/>
<text x="522" y="325" fill="#A5B4FC" font-family="Inter,sans-serif" font-size="11" font-weight="600">◈ ActionGraph</text>
<text x="522" y="341" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">in-memory · Nickel nodes</text>
<text x="522" y="355" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">topo-sort · cycle-detect</text>
<!-- PipelineContext -->
<rect x="690" y="305" width="171" height="72" rx="6" fill="url(#mod-pc-grad)" stroke="#06B6D4" stroke-width=".7" opacity=".9"/>
<text x="702" y="325" fill="#A5B4FC" font-family="Inter,sans-serif" font-size="11" font-weight="600">⬡ PipelineCtx</text>
<text x="702" y="341" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">DB-first · typed caps</text>
<text x="702" y="355" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">schema-validated</text>
<!-- StageRunner -->
<rect x="510" y="389" width="170" height="130" rx="6" fill="url(#mod-sr-grad)" stroke="#10B981" stroke-width=".7" opacity=".9"/>
<text x="522" y="407" fill="#A5B4FC" font-family="Inter,sans-serif" font-size="11" font-weight="600">▶ StageRunner</text>
<text x="522" y="423" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">JoinSet · parallel stages</text>
<text x="522" y="437" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">CancellationToken</text>
<text x="522" y="451" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">retry + backoff on failure</text>
<text x="522" y="465" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">saga compensate.nu</text>
<!-- RuleEngine (Cedar + NKey as named sub-sections) -->
<rect x="690" y="389" width="171" height="130" rx="6" fill="url(#mod-re-grad)" stroke="#F59E0B" stroke-width=".7" opacity=".9"/>
<text x="702" y="407" fill="#A5B4FC" font-family="Inter,sans-serif" font-size="11" font-weight="600">⚙ RuleEngine</text>
<rect x="810" y="393" width="44" height="13" rx="3" fill="#1c1700" stroke="#F59E0B" stroke-width=".6"/>
<text x="832" y="402" text-anchor="middle" fill="#F59E0B" font-family="'IBM Plex Mono',monospace" font-size="7">Cedar</text>
<line x1="702" y1="415" x2="854" y2="415" stroke="#4F46E5" stroke-width=".4" opacity=".5"/>
<text x="702" y="428" fill="#F59E0B" font-family="'IBM Plex Mono',monospace" font-size="8" font-weight="600">◈ Cedar</text>
<text x="702" y="441" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">permit · forbid · conditions</text>
<text x="702" y="454" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">per-node authz policies</text>
<line x1="702" y1="463" x2="854" y2="463" stroke="#4F46E5" stroke-width=".4" opacity=".5"/>
<text x="702" y="476" fill="#F59E0B" font-family="'IBM Plex Mono',monospace" font-size="8" font-weight="600">◈ NKey</text>
<text x="702" y="489" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">ed25519 asymmetric keys</text>
<text x="702" y="502" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">JWT per-process · verify</text>
<text x="685" y="538" text-anchor="middle" fill="#F87171" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".75">↺ Saga rollback on failure · compensate.nu in reverse</text>
<!-- ─── RIGHT PANEL: DATA STORES (x=926, w=232, ends x=1158) ─── -->
<!-- Orch right x=873 → stores: flechas a sus centros verticales (cajas 25px arriba) -->
<path d="M 873,256 L 926,216" stroke="#A78BFA" stroke-width="1.5" stroke-dasharray="4 3" class="flow" marker-end="url(#arr-purple)" fill="none" opacity=".8"/>
<path d="M 873,350 L 926,310" stroke="#F59E0B" stroke-width="1.5" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-gold)" fill="none" opacity=".8"/>
<path d="M 873,444 L 926,404" stroke="#06B6D4" stroke-width="1" stroke-dasharray="3 4" class="flow-slow" marker-end="url(#arr-dcyan)" fill="none" opacity=".7"/>
<!-- SurrealDB -->
<rect x="926" y="175" width="197" height="82" rx="8" fill="url(#surreal-grad)" filter="url(#shadow)" class="hb hb-d2"/>
<rect x="926" y="175" width="3" height="82" rx="1" fill="#A78BFA"/>
<rect x="926" y="175" width="197" height="82" rx="8" fill="none" stroke="#A78BFA" stroke-width=".8" opacity=".5"/>
<text x="941" y="198" fill="#A78BFA" font-family="Inter,sans-serif" font-size="12" font-weight="700">SurrealDB</text>
<text x="941" y="215" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">Pipeline state · Step results</text>
<text x="941" y="231" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">orchestrator_state ns</text>
<text x="941" y="247" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">crash recovery</text>
<!-- SecretumVault -->
<rect x="926" y="269" width="197" height="82" rx="8" fill="url(#vault-grad)" filter="url(#shadow)" class="hb hb-d3"/>
<rect x="926" y="269" width="3" height="82" rx="1" fill="#F59E0B"/>
<rect x="926" y="269" width="197" height="82" rx="8" fill="none" stroke="#F59E0B" stroke-width=".8" opacity=".5"/>
<text x="941" y="292" fill="#F59E0B" font-family="Inter,sans-serif" font-size="12" font-weight="700">SecretumVault</text>
<text x="941" y="309" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">Credentials · TTL leases</text>
<text x="941" y="325" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">vault:/secret/...</text>
<text x="941" y="341" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">never in NATS payload</text>
<!-- Zot OCI -->
<rect x="926" y="363" width="197" height="82" rx="8" fill="url(#oci-grad)" filter="url(#shadow)" class="hb hb-d4"/>
<rect x="926" y="363" width="3" height="82" rx="1" fill="#06B6D4"/>
<rect x="926" y="363" width="197" height="82" rx="8" fill="none" stroke="#06B6D4" stroke-width=".8" opacity=".5"/>
<text x="941" y="386" fill="#06B6D4" font-family="Inter,sans-serif" font-size="12" font-weight="700">Zot OCI Registry</text>
<text x="941" y="403" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">Node defs · Nickel libs</text>
<text x="941" y="419" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">oci://registry/nodes/</text>
<text x="941" y="435" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">content-addressed · signed</text>
<!-- ─── RIGHT PANEL: OPTIONAL SERVICES ─── -->
<text x="1042" y="533" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8" letter-spacing="2">OPTIONAL</text>
<!-- Flechas opcionales: escalonadas, cajas 40px más abajo -->
<path d="M 873,495 C 903,495 903,559 926,559" stroke="#64748B" stroke-width="1" stroke-dasharray="3 5" class="flow-vslow" marker-end="url(#arr-silver)" fill="none" opacity=".5"/>
<path d="M 873,510 C 905,510 905,653 926,653" stroke="#64748B" stroke-width="1" stroke-dasharray="3 5" class="flow-vslow" marker-end="url(#arr-silver)" fill="none" opacity=".5"/>
<path d="M 873,525 C 907,525 907,744 926,744" stroke="#64748B" stroke-width="1" stroke-dasharray="3 5" class="flow-vslow" marker-end="url(#arr-silver)" fill="none" opacity=".4"/>
<!-- Kogral -->
<rect x="926" y="518" width="197" height="82" rx="8" fill="#0F172A" stroke="#64748B" stroke-width=".8" stroke-dasharray="5 4" opacity=".7" class="hb hb-d2"/>
<text x="941" y="541" fill="#94A3B8" font-family="Inter,sans-serif" font-size="12" font-weight="600">Kogral</text>
<text x="941" y="558" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Knowledge graph</text>
<text x="941" y="574" fill="#4B5563" font-family="'IBM Plex Mono',monospace" font-size="8">node-updated triggers</text>
<rect x="1071" y="522" width="46" height="16" rx="3" fill="#1E293B"/>
<text x="1094" y="533" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">optional</text>
<!-- Syntaxis -->
<rect x="926" y="612" width="197" height="82" rx="8" fill="#0F172A" stroke="#64748B" stroke-width=".8" stroke-dasharray="5 4" opacity=".7" class="hb hb-d3"/>
<text x="941" y="635" fill="#94A3B8" font-family="Inter,sans-serif" font-size="12" font-weight="600">Syntaxis</text>
<text x="941" y="652" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Project orchestration</text>
<text x="941" y="668" fill="#4B5563" font-family="'IBM Plex Mono',monospace" font-size="8">phase-transition events</text>
<rect x="1071" y="616" width="46" height="16" rx="3" fill="#1E293B"/>
<text x="1094" y="627" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">optional</text>
<!-- TypeDialog -->
<rect x="926" y="706" width="197" height="75" rx="8" fill="#0F172A" stroke="#64748B" stroke-width=".8" stroke-dasharray="5 4" opacity=".6" class="hb hb-d4"/>
<text x="941" y="729" fill="#94A3B8" font-family="Inter,sans-serif" font-size="12" font-weight="600">TypeDialog</text>
<text x="941" y="746" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Service config UI</text>
<text x="941" y="764" fill="#4B5563" font-family="'IBM Plex Mono',monospace" font-size="8">startup config NCL only</text>
<!-- ─── LEFT: Git repo (x=10, y=295 — arriba izq junto a NATS) ─── -->
<!-- Orch → Git repo: bezier más curvada para alejarse de NATS -->
<path d="M 498,545 C 120,650 50,350 180,337" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" fill="none" opacity=".5"/>
<!-- Git repo → NATS: línea recta acortada 5px -->
<path d="M 180,337 L 230,353" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" fill="none" opacity=".6"/>
<rect x="30" y="295" width="150" height="85" rx="8" fill="url(#forgejo-grad)" filter="url(#shadow)" class="hb hb-d5"/>
<rect x="30" y="295" width="3" height="85" rx="1" fill="#F97316"/>
<rect x="30" y="295" width="150" height="85" rx="8" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="45" y="318" fill="#F97316" font-family="Inter,sans-serif" font-size="12" font-weight="700">Git repo</text>
<text x="45" y="335" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">Git events → NATS</text>
<text x="45" y="351" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">webhook → dev.crate.&gt;</text>
<text x="45" y="367" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">push · tag · pr</text>
<!-- ─── EXECUTION LAYER mismo nivel (Nu x=380 y=623, AI x=615 y=623, sep=35px) ─── -->
<!-- Orch → Nu Executor: sale borde inferior x=510 (20px dcha), U-curve por debajo, llega al fondo (480,733) apuntando arriba, acortada 30px -->
<path d="M 518,550 C 500,560 485,610 483,614" stroke="#10B981" stroke-width="1.5" stroke-dasharray="5 3" class="flow" marker-end="url(#arr-green)" fill="none" opacity=".8"/>
<!-- Orch → AI Agent: copia de Nu pero adaptada a AI, color indigo -->
<path d="M 773,550 C 755,560 717,610 714,616" stroke="#818CF8" stroke-width="1.5" stroke-dasharray="5 3" class="flow" marker-end="url(#arr-indigo)" fill="none" opacity=".8"/>
<rect x="380" y="623" width="200" height="80" rx="8" fill="url(#exec-grad)" filter="url(#shadow)" class="hb"/>
<rect x="380" y="623" width="3" height="80" rx="1" fill="#10B981"/>
<rect x="380" y="623" width="200" height="80" rx="8" fill="none" stroke="#10B981" stroke-width=".8" opacity=".5"/>
<text x="395" y="644" fill="#10B981" font-family="Inter,sans-serif" font-size="12" font-weight="700">Nu Executor</text>
<text x="395" y="660" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">Atomic steps · Pure functions</text>
<text x="395" y="675" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">scripts/nu/*.nu</text>
<text x="395" y="689" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">stdout=output · exit-code=status</text>
<rect x="615" y="623" width="200" height="80" rx="8" fill="url(#agent-grad)" filter="url(#shadow)" class="hb hb-d1"/>
<rect x="615" y="623" width="3" height="80" rx="1" fill="#818CF8"/>
<rect x="615" y="623" width="200" height="80" rx="8" fill="none" stroke="#818CF8" stroke-width=".8" opacity=".5"/>
<text x="630" y="644" fill="#818CF8" font-family="Inter,sans-serif" font-size="12" font-weight="700">AI Agent</text>
<text x="630" y="660" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">stratum-llm · NATS protocol</text>
<text x="630" y="675" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">dev.agent.*.requested/responded</text>
<text x="630" y="689" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">oneshot correlation · timeout</text>
<!-- ─── NICKEL BASE LIBRARY (x=390, y=738) ─── -->
<rect x="390" y="738" width="415" height="65" rx="8" fill="#0F172A" stroke="none"/>
<rect x="390" y="738" width="415" height="65" rx="8" fill="url(#ncl-grad)"/>
<rect x="390" y="738" width="415" height="65" rx="8" fill="none" stroke="#6366F1" stroke-width=".8" stroke-dasharray="6 4" opacity=".6"/>
<text x="597" y="761" text-anchor="middle" fill="#A5B4FC" font-family="Inter,sans-serif" font-size="12" font-weight="600">Nickel Base Library</text>
<text x="597" y="777" text-anchor="middle" fill="#64748B" font-family="Inter,sans-serif" font-size="9">OCI-published · content-addressed · build-verified · typecheck-gated</text>
<text x="597" y="791" text-anchor="middle" fill="#4B5563" font-family="'IBM Plex Mono',monospace" font-size="8">orchestrator-types.ncl · capability-schemas.ncl · defaults.ncl</text>
<!-- Zot OCI → Nickel: arco hacia adentro, sale desde Zot (cajas 40px abajo) -->
<path d="M 926,414 C 900,390 900,750 805,770" stroke="#06B6D4" stroke-width="1" stroke-dasharray="3 3" class="flow-slow" marker-end="url(#arr-dcyan)" fill="none" opacity=".6"/>
<!-- ─── LOGO above LEGEND ─── -->
<g transform="translate(8,5) scale(0.2)">
<g transform="translate(20,50)">
<rect x="-220" y="18" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.5;0.8;0.5" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<rect x="-220" y="88" width="220" height="24" rx="5" fill="url(#hpLayerGrad)">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.15s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
<rect x="-220" y="158" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.3s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.8;0.5;0.8" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<path d="M35 42 Q35 65 70 85 Q95 100 95 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.5s"/></path>
<path d="M185 42 Q185 65 150 85 Q125 100 125 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.6s"/></path>
<path d="M95 100 Q95 100 70 115 Q35 135 35 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.7s"/></path>
<path d="M125 100 Q125 100 150 115 Q185 135 185 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.8s"/></path>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.5s"><mpath href="#hpPathIn1"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.8s"><mpath href="#hpPathIn2"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.1s"><mpath href="#hpPathOut1"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.1s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.4s"><mpath href="#hpPathOut2"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.4s"/></circle>
<circle cx="35" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.25s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="185" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.3s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="35" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.4s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle cx="185" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.45s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<rect x="85" y="75" width="50" height="50" rx="9" fill="url(#hpProcessorGrad)" filter="url(#hpProcessorGlow)" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.3s" fill="freeze" begin="0.45s"/></rect>
<rect x="97" y="87" width="26" height="26" rx="5" fill="#ffffff" opacity="0"><animate attributeName="opacity" values="0;0.95" dur="0.25s" fill="freeze" begin="0.55s"/></rect>
<rect x="101" y="96" width="4" height="8" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.7s"/><animate attributeName="height" values="8;14;6;10;8" dur="0.6s" repeatCount="indefinite" begin="1.2s"/><animate attributeName="y" values="96;93;97;95;96" dur="0.6s" repeatCount="indefinite" begin="1.2s"/></rect>
<rect x="108" y="93" width="4" height="14" rx="1" fill="#06B6D4" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.75s"/><animate attributeName="height" values="14;8;12;16;14" dur="0.55s" repeatCount="indefinite" begin="1.25s"/><animate attributeName="y" values="93;96;94;92;93" dur="0.55s" repeatCount="indefinite" begin="1.25s"/></rect>
<rect x="115" y="95" width="4" height="10" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.8s"/><animate attributeName="height" values="10;16;8;12;10" dur="0.5s" repeatCount="indefinite" begin="1.3s"/><animate attributeName="y" values="95;92;96;94;95" dur="0.5s" repeatCount="indefinite" begin="1.3s"/></rect>
<rect x="85" y="75" width="50" height="50" rx="9" fill="none" stroke="#22D3EE" stroke-width="1.5" opacity="0"><animate attributeName="x" values="85;70" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="y" values="75;60" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="width" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="height" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="rx" values="9;14" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="opacity" values="0.5;0" dur="2s" repeatCount="indefinite" begin="1.5s"/></rect>
</g>
<g clip-path="url(#hpTextReveal)">
<text x="280" y="175" font-family="'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-weight="700" font-size="72" fill="url(#hpShimmer)">Stratum<tspan fill="url(#hpProcessorGrad)">I</tspan>Ops</text>
</g>
<rect x="280" y="115" width="4" height="70" rx="2" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1;1;0;0" dur="0.8s" fill="freeze" begin="1s" keyTimes="0;0.1;0.5;0.51;1"/><animate attributeName="x" values="280;980" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></rect>
<line x1="280" y1="195" x2="280" y2="195" stroke="url(#hpUnderlineGrad)" stroke-width="3" stroke-linecap="round" opacity="0"><animate attributeName="opacity" values="0;0.7" dur="0.1s" fill="freeze" begin="1.4s"/><animate attributeName="x2" values="280;750" dur="0.8s" fill="freeze" begin="1.4s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></line>
</g>
<!-- ─── LEGEND (y=718, x=45, w=270) ─── -->
<rect x="45" y="718" width="270" height="88" rx="6" fill="#0c1018" stroke="#1E293B" stroke-width=".8"/>
<text x="180" y="736" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8" letter-spacing="2">LEGEND</text>
<line x1="55" y1="750" x2="85" y2="750" stroke="#22D3EE" stroke-width="2" stroke-dasharray="6 3"/>
<text x="91" y="754" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">event flow (NATS)</text>
<line x1="55" y1="768" x2="85" y2="768" stroke="#F59E0B" stroke-width="2" stroke-dasharray="6 3"/>
<text x="91" y="772" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">auth / credentials</text>
<line x1="55" y1="786" x2="85" y2="786" stroke="#10B981" stroke-width="2" stroke-dasharray="6 3"/>
<text x="91" y="790" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">execution</text>
<line x1="213" y1="750" x2="243" y2="750" stroke="#A78BFA" stroke-width="2" stroke-dasharray="6 3"/>
<text x="249" y="754" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">state (DB)</text>
<line x1="213" y1="768" x2="243" y2="768" stroke="#06B6D4" stroke-width="2" stroke-dasharray="6 3"/>
<text x="249" y="772" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">OCI / registry</text>
<line x1="213" y1="786" x2="243" y2="786" stroke="#64748B" stroke-width="1.5" stroke-dasharray="3 5"/>
<text x="249" y="790" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">optional</text>
<!-- ─── BRANDING ─── -->
<text x="1140" y="817" text-anchor="end" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="9">stratumiops · v0.1</text>
</svg>

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -0,0 +1,450 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1600 820" width="1600" height="820">
<defs>
<filter id="glow-c" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="glow-g" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="glow-i" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<marker id="ac" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#22D3EE"/>
</marker>
<marker id="ai" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#6366F1"/>
</marker>
<marker id="ag" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#F59E0B"/>
</marker>
<marker id="agr" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#10B981"/>
</marker>
<marker id="ap" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#A78BFA"/>
</marker>
<marker id="ar" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#F43F5E"/>
</marker>
<marker id="aib" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#818CF8"/>
</marker>
<marker id="ail" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#A5B4FC"/>
</marker>
<marker id="ao" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#F97316"/>
</marker>
<!-- ─── stratumiops-h logo defs ─── -->
<linearGradient id="hpLayerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#4F46E5"/>
</linearGradient>
<linearGradient id="hpProcessorGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#06B6D4"/>
</linearGradient>
<linearGradient id="hpFlowGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="0%;100%;0%" dur="2.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="100%;200%;100%" dur="2.5s" repeatCount="indefinite"/>
</linearGradient>
<linearGradient id="hpUnderlineGrad" x1="280" y1="195" x2="750" y2="195" gradientUnits="userSpaceOnUse">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
</linearGradient>
<linearGradient id="hpShimmer" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="40%" style="stop-color:#6366F1"/>
<stop offset="50%" style="stop-color:#22D3EE"/><stop offset="60%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="-100%;100%" dur="3s" repeatCount="indefinite" begin="2s"/>
<animate attributeName="x2" values="0%;200%" dur="3s" repeatCount="indefinite" begin="2s"/>
</linearGradient>
<filter id="hpProcessorGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="5" result="coloredBlur"><animate attributeName="stdDeviation" values="4;8;4" dur="1.5s" repeatCount="indefinite"/></feGaussianBlur>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="hpNodeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<clipPath id="hpTextReveal">
<rect x="280" y="80" width="0" height="150">
<animate attributeName="width" values="0;800" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
</clipPath>
<path id="hpPathIn1" d="M25 40 Q25 65 55 80 Q85 95 95 100" fill="none"/>
<path id="hpPathIn2" d="M215 40 Q215 65 185 80 Q155 95 145 100" fill="none"/>
<path id="hpPathOut1" d="M95 100 Q85 105 55 120 Q25 135 25 160" fill="none"/>
<path id="hpPathOut2" d="M145 100 Q155 105 185 120 Q215 135 215 160" fill="none"/>
</defs>
<style>
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.65} }
@keyframes nats-glow { 0%,100%{stroke-opacity:1;filter:url(#glow-c)} 50%{stroke-opacity:0.5;filter:none} }
@keyframes auth-glow { 0%,100%{stroke-opacity:1;filter:url(#glow-g)} 50%{stroke-opacity:0.4;filter:none} }
@keyframes flow-dash { to{stroke-dashoffset:-20} }
@keyframes flow-dash-slow { to{stroke-dashoffset:-20} }
.fd { animation: flow-dash 1.2s linear infinite; }
.fds { animation: flow-dash-slow 2s linear infinite; }
.nd { animation: nats-glow 1.8s ease-in-out infinite; }
.ah { animation: auth-glow 2s ease-in-out infinite; }
.p1 { animation: pulse 2.4s ease-in-out 0s infinite; }
.p2 { animation: pulse 2.4s ease-in-out 0.2s infinite; }
.p3 { animation: pulse 2.4s ease-in-out 0.4s infinite; }
.p4 { animation: pulse 2.4s ease-in-out 0.6s infinite; }
.p5 { animation: pulse 2.4s ease-in-out 0.8s infinite; }
.p6 { animation: pulse 2.4s ease-in-out 1.0s infinite; }
.p7 { animation: pulse 2.4s ease-in-out 1.2s infinite; }
.p8 { animation: pulse 2.4s ease-in-out 1.4s infinite; }
.p9 { animation: pulse 2.4s ease-in-out 1.6s infinite; }
.p10 { animation: pulse 2.4s ease-in-out 1.8s infinite; }
.p11 { animation: pulse 2.4s ease-in-out 0.3s infinite; }
.p12 { animation: pulse 2.4s ease-in-out 0.5s infinite; }
</style>
<!-- Background -->
<rect width="1600" height="820" fill="#0F172A"/>
<!-- TITLE BAR -->
<rect x="0" y="0" width="1600" height="2" fill="#22D3EE"/>
<rect x="0" y="0" width="1600" height="99" fill="#0A1020"/>
<text x="800" y="40" font-family="'IBM Plex Mono',monospace" font-size="19" fill="#22D3EE" text-anchor="middle" font-weight="500" letter-spacing="2">STRATUM ORCHESTRATOR · BUILD PIPELINE FLOW</text>
<text x="800" y="66" font-family="'IBM Plex Mono',monospace" font-size="12" fill="#64748B" text-anchor="middle">event: dev.crate.provisioning-cli.modified · trigger → ActionGraph → stages → state → emit</text>
<rect x="0" y="98" width="1600" height="2" fill="#1E293B"/>
<!-- LANE LABEL COLUMN SEPARATOR -->
<line x1="182" y1="100" x2="182" y2="742" stroke="#1E293B" stroke-width="2"/>
<!-- Lane 0: Developer / Forgejo -->
<rect x="0" y="100" width="6" height="80" fill="#F97316"/>
<rect x="6" y="100" width="176" height="80" fill="#F97316" fill-opacity="0.07"/>
<text x="22" y="131" font-family="'Inter',sans-serif" font-size="10" fill="#F97316" font-weight="600">Developer · Forgejo</text>
<text x="22" y="147" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">git push / webhook</text>
<!-- Lane 1: NATS JetStream -->
<rect x="0" y="180" width="6" height="80" fill="#22D3EE"/>
<rect x="6" y="180" width="176" height="80" fill="#22D3EE" fill-opacity="0.05"/>
<text x="22" y="211" font-family="'Inter',sans-serif" font-size="10" fill="#22D3EE" font-weight="600">NATS JetStream</text>
<text x="22" y="227" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">Event Bus</text>
<!-- Lane 2: Orchestrator Planning -->
<rect x="0" y="260" width="6" height="80" fill="#6366F1"/>
<rect x="6" y="260" width="176" height="80" fill="#6366F1" fill-opacity="0.05"/>
<text x="22" y="291" font-family="'Inter',sans-serif" font-size="10" fill="#6366F1" font-weight="600">Orchestrator</text>
<text x="22" y="307" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">Planning</text>
<!-- Lane 3: Auth -->
<rect x="0" y="340" width="6" height="80" fill="#F59E0B"/>
<rect x="6" y="340" width="176" height="80" fill="#F59E0B" fill-opacity="0.05"/>
<text x="22" y="371" font-family="'Inter',sans-serif" font-size="10" fill="#F59E0B" font-weight="600">Auth · NKeys</text>
<text x="22" y="387" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">+ Cedar</text>
<!-- Lane 4: Stage 0 Parallel -->
<rect x="0" y="420" width="6" height="80" fill="#10B981"/>
<rect x="6" y="420" width="176" height="80" fill="#10B981" fill-opacity="0.05"/>
<text x="22" y="451" font-family="'Inter',sans-serif" font-size="10" fill="#10B981" font-weight="600">Stage 0</text>
<text x="22" y="467" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">‖ Parallel</text>
<!-- Lane 5: Stage 1 -->
<rect x="0" y="500" width="6" height="80" fill="#818CF8"/>
<rect x="6" y="500" width="176" height="80" fill="#818CF8" fill-opacity="0.04"/>
<text x="22" y="531" font-family="'Inter',sans-serif" font-size="10" fill="#818CF8" font-weight="600">Stage 1</text>
<text x="22" y="547" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">→ Sequential</text>
<!-- Lane 6: Stage 2 -->
<rect x="0" y="580" width="6" height="80" fill="#A5B4FC"/>
<rect x="6" y="580" width="176" height="80" fill="#A5B4FC" fill-opacity="0.03"/>
<text x="22" y="611" font-family="'Inter',sans-serif" font-size="10" fill="#A5B4FC" font-weight="600">Stage 2</text>
<text x="22" y="627" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">→ Sequential</text>
<!-- Lane 7: SurrealDB -->
<rect x="0" y="660" width="6" height="80" fill="#A78BFA"/>
<rect x="6" y="660" width="176" height="80" fill="#A78BFA" fill-opacity="0.05"/>
<text x="22" y="691" font-family="'Inter',sans-serif" font-size="10" fill="#A78BFA" font-weight="600">SurrealDB</text>
<text x="22" y="707" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">State</text>
<!-- LANE DIVIDERS (content area) -->
<line x1="182" y1="180" x2="1590" y2="180" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="260" x2="1590" y2="260" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="340" x2="1590" y2="340" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="420" x2="1590" y2="420" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="500" x2="1590" y2="500" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="580" x2="1590" y2="580" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="660" x2="1590" y2="660" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="740" x2="1590" y2="740" stroke="#1E293B" stroke-width="1"/>
<!-- TIMELINE BASELINE (dashed, very subtle) -->
<path id="tl" d="M200,140 L1560,140" fill="none" stroke="#F97316" stroke-width="1" stroke-dasharray="5,4" opacity="0.2" class="fd"/>
<!-- ANIMATED PARTICLES along timeline -->
<circle r="5" fill="#22D3EE" opacity="0.85" filter="url(#glow-c)">
<animateMotion dur="6s" repeatCount="indefinite" begin="0s">
<mpath xlink:href="#tl"/>
</animateMotion>
</circle>
<circle r="4" fill="#6366F1" opacity="0.7">
<animateMotion dur="6s" repeatCount="indefinite" begin="2s">
<mpath xlink:href="#tl"/>
</animateMotion>
</circle>
<circle r="3.5" fill="#F59E0B" opacity="0.65">
<animateMotion dur="6s" repeatCount="indefinite" begin="4s">
<mpath xlink:href="#tl"/>
</animateMotion>
</circle>
<!-- ========== STEP 1: git push (Developer, x=220, yc=140) ========== -->
<g class="p1">
<circle cx="220" cy="112" r="9" fill="#F97316"/>
<text x="220" y="116" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">1</text>
<rect x="185" y="120" width="70" height="40" rx="5" fill="#F97316" fill-opacity="0.18" stroke="#F97316" stroke-width="1.5"/>
<text x="220" y="136" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F97316" text-anchor="middle" font-weight="500">git push</text>
<text x="220" y="150" font-family="'Inter',sans-serif" font-size="8" fill="#94A3B8" text-anchor="middle">code change</text>
<text x="220" y="170" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#475569" text-anchor="middle">commit: abc123f</text>
</g>
<!-- ========== STEP 2: webhook→NATS diamond (spans Developer/NATS border at y=180, x=330) ========== -->
<g class="nd p2">
<circle cx="330" cy="112" r="9" fill="#F97316"/>
<text x="330" y="116" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">2</text>
<polygon points="330,128 400,178 330,228 260,178" fill="#22D3EE" fill-opacity="0.12" stroke="#22D3EE" stroke-width="1.5"/>
<text x="330" y="163" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">dev.crate.</text>
<text x="330" y="178" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">provisioning-cli</text>
<text x="330" y="193" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">.modified</text>
<rect x="283" y="220" width="96" height="24" rx="3" fill="#0A1020" stroke="#1E293B" stroke-width="1"/>
<text x="330" y="231" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#475569" text-anchor="middle">&#123;"crate":"prvng-cli",</text>
<text x="330" y="241" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#475569" text-anchor="middle">"sha":"abc123f"&#125;</text>
</g>
<!-- ========== STEP 3: pull_batch(10) (Orchestrator, x=450, yc=300) ========== -->
<g class="p3">
<circle cx="450" cy="268" r="9" fill="#6366F1"/>
<text x="450" y="272" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">3</text>
<rect x="415" y="277" width="70" height="44" rx="5" fill="#6366F1" fill-opacity="0.18" stroke="#6366F1" stroke-width="1.5"/>
<text x="450" y="294" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#818CF8" text-anchor="middle" font-weight="500">pull_batch</text>
<text x="450" y="307" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#818CF8" text-anchor="middle">(10)</text>
<text x="450" y="332" font-family="'Inter',sans-serif" font-size="8" fill="#475569" text-anchor="middle">JetStream consumer</text>
</g>
<!-- ========== STEP 4: NKey verify (Auth hexagon, x=565, yc=382) ========== -->
<g class="ah p4">
<circle cx="565" cy="348" r="9" fill="#F59E0B"/>
<text x="565" y="352" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">4</text>
<polygon points="565,352 601,370 601,401 565,419 530,401 530,370" fill="#F59E0B" fill-opacity="0.12" stroke="#F59E0B" stroke-width="1.5"/>
<text x="565" y="381" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">NKey</text>
<text x="565" y="394" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">verify</text>
<text x="565" y="428" font-family="'Inter',sans-serif" font-size="8" fill="#475569" text-anchor="middle">publisher auth</text>
</g>
<!-- ========== STEP 5: ActionGraph query+topo-sort (Orchestrator, x=688, yc=299, wider) ========== -->
<g class="p5">
<circle cx="688" cy="268" r="9" fill="#6366F1"/>
<text x="688" y="272" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">5</text>
<rect x="644" y="277" width="88" height="46" rx="5" fill="#6366F1" fill-opacity="0.22" stroke="#6366F1" stroke-width="2" filter="url(#glow-i)"/>
<text x="688" y="293" font-family="'IBM Plex Mono',monospace" font-size="8.5" fill="#A5B4FC" text-anchor="middle" font-weight="600">ActionGraph</text>
<text x="688" y="306" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#818CF8" text-anchor="middle">query + topo-sort</text>
<!-- topo order annotation -->
<rect x="644" y="330" width="88" height="36" rx="3" fill="#0A1020" stroke="#1E293B" stroke-width="1"/>
<text x="688" y="341" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#475569" text-anchor="middle">[lint-crate, fmt-crate]</text>
<text x="688" y="351" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#475569" text-anchor="middle">→ build-crate</text>
<text x="688" y="361" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#475569" text-anchor="middle">→ install</text>
<!-- Stages bracket -->
<line x1="740" y1="277" x2="740" y2="502" stroke="#334155" stroke-width="1" stroke-dasharray="3,3"/>
<text x="754" y="396" font-family="'Inter',sans-serif" font-size="8" fill="#334155" text-anchor="middle" transform="rotate(90,754,396)">Stages</text>
</g>
<!-- ========== STEP 6: Cedar authorize (Auth hexagon, x=800, yc=382) ========== -->
<g class="ah p6">
<circle cx="800" cy="348" r="9" fill="#F59E0B"/>
<text x="800" y="352" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">6</text>
<polygon points="800,352 836,370 836,401 800,419 765,401 765,370" fill="#F59E0B" fill-opacity="0.12" stroke="#F59E0B" stroke-width="1.5"/>
<text x="800" y="381" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">Cedar</text>
<text x="800" y="394" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">authorize</text>
<text x="800" y="425" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#475569" text-anchor="middle">permit: trigger</text>
<text x="800" y="435" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#475569" text-anchor="middle">node:build-crate</text>
</g>
<!-- ========== STEP 7: Stage 0 PARALLEL wide box (Stage 0, x=876-1036, yc=460) ========== -->
<!-- parallel bracket above -->
<path d="M878,428 L878,423 L1038,423 L1038,428" fill="none" stroke="#10B981" stroke-width="1.2" stroke-dasharray="3,2"/>
<text x="958" y="420" font-family="'Inter',sans-serif" font-size="9" fill="#10B981" text-anchor="middle" font-weight="600">‖ parallel · JoinSet</text>
<g class="p7">
<circle cx="878" cy="430" r="9" fill="#10B981"/>
<text x="878" y="434" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">7</text>
<!-- wide rect -->
<rect x="878" y="437" width="160" height="44" rx="5" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1.5"/>
<!-- center divider -->
<line x1="958" y1="437" x2="958" y2="481" stroke="#10B981" stroke-width="1" stroke-dasharray="3,2" opacity="0.6"/>
<!-- LEFT: lint.nu -->
<text x="918" y="455" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#10B981" text-anchor="middle" font-weight="500">lint.nu</text>
<text x="918" y="469" font-family="'Inter',sans-serif" font-size="8" fill="#94A3B8" text-anchor="middle">(clippy)</text>
<!-- RIGHT: fmt.nu -->
<text x="998" y="455" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#10B981" text-anchor="middle" font-weight="500">fmt.nu</text>
<text x="998" y="469" font-family="'Inter',sans-serif" font-size="8" fill="#94A3B8" text-anchor="middle">(cargo fmt)</text>
</g>
<!-- join arc below -->
<path d="M878,481 L878,494 L1038,494 L1038,481" fill="none" stroke="#10B981" stroke-width="1" stroke-dasharray="3,2" opacity="0.5"/>
<text x="958" y="506" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#475569" text-anchor="middle">join_all → PipelineContext</text>
<!-- ========== STEP 8: deposit capabilities (Orchestrator, x=1075, yc=300) ========== -->
<g class="p8">
<circle cx="1075" cy="268" r="9" fill="#6366F1"/>
<text x="1075" y="272" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">8</text>
<rect x="1038" y="277" width="74" height="46" rx="5" fill="#6366F1" fill-opacity="0.15" stroke="#6366F1" stroke-width="1.5"/>
<text x="1075" y="293" font-family="'IBM Plex Mono',monospace" font-size="8.5" fill="#818CF8" text-anchor="middle" font-weight="500">deposit:</text>
<text x="1075" y="307" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#94A3B8" text-anchor="middle">linted-code</text>
<text x="1075" y="318" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#94A3B8" text-anchor="middle">formatted-code</text>
</g>
<!-- ========== STEP 9: Stage 1: build.nu (Stage 1, x=1190, yc=540) ========== -->
<g class="p9">
<circle cx="1190" cy="508" r="9" fill="#818CF8"/>
<text x="1190" y="512" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">9</text>
<rect x="1153" y="517" width="74" height="44" rx="5" fill="#818CF8" fill-opacity="0.15" stroke="#818CF8" stroke-width="1.5"/>
<text x="1190" y="533" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#818CF8" text-anchor="middle" font-weight="500">build.nu</text>
<text x="1190" y="546" font-family="'Inter',sans-serif" font-size="8" fill="#94A3B8" text-anchor="middle">(cargo build)</text>
<!-- Vault annotation -->
<rect x="1153" y="567" width="74" height="22" rx="3" fill="#0A1020" stroke="#F59E0B" stroke-width="1" stroke-dasharray="3,2"/>
<text x="1190" y="578" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#F59E0B" text-anchor="middle">&#x1F510; Vault cred</text>
<text x="1190" y="588" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#F59E0B" text-anchor="middle">TTL=step timeout</text>
<text x="1190" y="603" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#475569" text-anchor="middle">deposit: built-artifact</text>
</g>
<!-- ========== STEP 10: Stage 2: install.nu (Stage 2, x=1310, yc=620) ========== -->
<g class="p10">
<circle cx="1310" cy="588" r="9" fill="#A5B4FC"/>
<text x="1310" y="592" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">10</text>
<rect x="1263" y="597" width="94" height="44" rx="5" fill="#A5B4FC" fill-opacity="0.12" stroke="#A5B4FC" stroke-width="1.5"/>
<text x="1310" y="613" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#A5B4FC" text-anchor="middle" font-weight="500">install.nu</text>
<text x="1310" y="625" font-family="'Inter',sans-serif" font-size="8" fill="#94A3B8" text-anchor="middle">extracts: built-artifact</text>
<text x="1310" y="650" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#475569" text-anchor="middle">deposit: installed</text>
</g>
<!-- ========== STEP 11: pipeline_run → SurrealDB (SurrealDB, x=1420, yc=700) ========== -->
<g class="p11">
<circle cx="1420" cy="668" r="9" fill="#A78BFA"/>
<text x="1420" y="672" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">11</text>
<rect x="1373" y="677" width="94" height="44" rx="5" fill="#A78BFA" fill-opacity="0.15" stroke="#A78BFA" stroke-width="1.5"/>
<text x="1420" y="693" font-family="'IBM Plex Mono',monospace" font-size="8.5" fill="#A78BFA" text-anchor="middle" font-weight="500">pipeline_run</text>
<text x="1420" y="706" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#94A3B8" text-anchor="middle">status: success</text>
<text x="1420" y="727" font-family="'Inter',sans-serif" font-size="7.5" fill="#475569" text-anchor="middle">all steps persisted</text>
</g>
<!-- ========== STEP 12: emit result event (NATS diamond, x=1530, yc=220) ========== -->
<g class="nd p12">
<circle cx="1530" cy="178" r="9" fill="#22D3EE"/>
<text x="1530" y="182" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">12</text>
<polygon points="1530,185 1592,220 1530,255 1468,220" fill="#22D3EE" fill-opacity="0.12" stroke="#22D3EE" stroke-width="1.5"/>
<text x="1530" y="208" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">dev.crate.</text>
<text x="1530" y="221" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">provisioning-cli</text>
<text x="1530" y="234" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">.built</text>
<text x="1530" y="264" font-family="'Inter',sans-serif" font-size="7.5" fill="#475569" text-anchor="middle">duration_ms, status</text>
</g>
<!-- ========== FLOW ARROWS ========== -->
<!-- 1→2: git push triggers webhook (Developer lane →) -->
<line x1="255" y1="140" x2="292" y2="162" stroke="#F97316" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#ao)" class="fd"/>
<!-- 2→3: NATS event received (cross-lane: NATS down to Orchestrator) -->
<line x1="330" y1="216" x2="330" y2="270" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ai)"/>
<line x1="330" y1="299" x2="415" y2="299" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#ai)" class="fds"/>
<!-- 3→4: Orchestrator requests NKey verify (cross-lane down to Auth) -->
<line x1="487" y1="299" x2="525" y2="365" stroke="#F59E0B" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ag)"/>
<!-- 4→5: Auth ok, Orchestrator proceeds (cross-lane back up + forward) -->
<line x1="606" y1="385" x2="644" y2="299" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ai)"/>
<!-- 5→6: ActionGraph built, Cedar authorize -->
<line x1="732" y1="300" x2="760" y2="365" stroke="#F59E0B" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ag)"/>
<!-- 6→7: Auth permits, launch Stage 0 -->
<line x1="833" y1="398" x2="878" y2="445" stroke="#10B981" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#agr)"/>
<!-- 7→8: Stage 0 join → deposit to Orchestrator -->
<line x1="1038" y1="458" x2="1075" y2="323" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ai)"/>
<!-- 8→9: Deposit done → Stage 1 -->
<line x1="1075" y1="323" x2="1153" y2="517" stroke="#818CF8" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#aib)"/>
<!-- 9→10: Stage 1 done → Stage 2 -->
<line x1="1227" y1="540" x2="1263" y2="617" stroke="#A5B4FC" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ail)"/>
<!-- 10→11: Stage 2 done → SurrealDB ACK -->
<line x1="1310" y1="641" x2="1373" y2="693" stroke="#A78BFA" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ap)"/>
<!-- 11→12: SurrealDB persisted → emit result NATS -->
<path d="M1420,677 L1420,220 L1474,220" fill="none" stroke="#22D3EE" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#ac)" class="fds"/>
<!-- Orchestrator ↔ SurrealDB (bidirectional state) -->
<path d="M1075,323 Q1075,700 1373,700" fill="none" stroke="#A78BFA" stroke-width="1" stroke-dasharray="3,3" opacity="0.4"/>
<!-- ========== ROLLBACK PATH ========== -->
<path d="M1153,574 L800,574" fill="none" stroke="#F43F5E" stroke-width="2" stroke-dasharray="7,4" marker-end="url(#ar)" opacity="0.85"/>
<rect x="850" y="553" width="210" height="17" rx="3" fill="#0A1020" fill-opacity="0.95"/>
<text x="955" y="565" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#F43F5E" text-anchor="middle">rollback path: compensate.nu in reverse order</text>
<!-- ========== LOGO above LEGEND ========== -->
<g transform="translate(10,15) scale(0.2)">
<g transform="translate(20,50)">
<rect x="-220" y="18" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.5;0.8;0.5" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<rect x="-220" y="88" width="220" height="24" rx="5" fill="url(#hpLayerGrad)">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.15s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
<rect x="-220" y="158" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.3s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.8;0.5;0.8" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<path d="M35 42 Q35 65 70 85 Q95 100 95 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.5s"/></path>
<path d="M185 42 Q185 65 150 85 Q125 100 125 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.6s"/></path>
<path d="M95 100 Q95 100 70 115 Q35 135 35 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.7s"/></path>
<path d="M125 100 Q125 100 150 115 Q185 135 185 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.8s"/></path>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.5s"><mpath href="#hpPathIn1"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.8s"><mpath href="#hpPathIn2"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.1s"><mpath href="#hpPathOut1"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.1s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.4s"><mpath href="#hpPathOut2"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.4s"/></circle>
<circle cx="35" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.25s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="185" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.3s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="35" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.4s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle cx="185" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.45s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<rect x="85" y="75" width="50" height="50" rx="9" fill="url(#hpProcessorGrad)" filter="url(#hpProcessorGlow)" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.3s" fill="freeze" begin="0.45s"/></rect>
<rect x="97" y="87" width="26" height="26" rx="5" fill="#ffffff" opacity="0"><animate attributeName="opacity" values="0;0.95" dur="0.25s" fill="freeze" begin="0.55s"/></rect>
<rect x="101" y="96" width="4" height="8" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.7s"/><animate attributeName="height" values="8;14;6;10;8" dur="0.6s" repeatCount="indefinite" begin="1.2s"/><animate attributeName="y" values="96;93;97;95;96" dur="0.6s" repeatCount="indefinite" begin="1.2s"/></rect>
<rect x="108" y="93" width="4" height="14" rx="1" fill="#06B6D4" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.75s"/><animate attributeName="height" values="14;8;12;16;14" dur="0.55s" repeatCount="indefinite" begin="1.25s"/><animate attributeName="y" values="93;96;94;92;93" dur="0.55s" repeatCount="indefinite" begin="1.25s"/></rect>
<rect x="115" y="95" width="4" height="10" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.8s"/><animate attributeName="height" values="10;16;8;12;10" dur="0.5s" repeatCount="indefinite" begin="1.3s"/><animate attributeName="y" values="95;92;96;94;95" dur="0.5s" repeatCount="indefinite" begin="1.3s"/></rect>
<rect x="85" y="75" width="50" height="50" rx="9" fill="none" stroke="#22D3EE" stroke-width="1.5" opacity="0"><animate attributeName="x" values="85;70" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="y" values="75;60" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="width" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="height" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="rx" values="9;14" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="opacity" values="0.5;0" dur="2s" repeatCount="indefinite" begin="1.5s"/></rect>
</g>
<g clip-path="url(#hpTextReveal)">
<text x="280" y="175" font-family="'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-weight="700" font-size="72" fill="url(#hpShimmer)">Stratum<tspan fill="url(#hpProcessorGrad)">I</tspan>Ops</text>
</g>
<rect x="280" y="115" width="4" height="70" rx="2" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1;1;0;0" dur="0.8s" fill="freeze" begin="1s" keyTimes="0;0.1;0.5;0.51;1"/><animate attributeName="x" values="280;980" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></rect>
<line x1="280" y1="195" x2="280" y2="195" stroke="url(#hpUnderlineGrad)" stroke-width="3" stroke-linecap="round" opacity="0"><animate attributeName="opacity" values="0;0.7" dur="0.1s" fill="freeze" begin="1.4s"/><animate attributeName="x2" values="280;750" dur="0.8s" fill="freeze" begin="1.4s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></line>
</g>
<!-- ========== LEGEND (y=760-800) ========== -->
<text x="192" y="770" font-family="'Inter',sans-serif" font-size="9" fill="#475569" font-weight="600">Legend:</text>
<rect x="240" y="759" width="11" height="11" rx="2" fill="#22D3EE" fill-opacity="0.25" stroke="#22D3EE" stroke-width="1"/>
<text x="256" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#94A3B8">NATS events</text>
<rect x="335" y="759" width="11" height="11" rx="2" fill="#6366F1" fill-opacity="0.25" stroke="#6366F1" stroke-width="1"/>
<text x="351" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#94A3B8">Orchestrator</text>
<rect x="434" y="759" width="11" height="11" rx="2" fill="#F59E0B" fill-opacity="0.25" stroke="#F59E0B" stroke-width="1"/>
<text x="450" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#94A3B8">Auth (NKeys + Cedar)</text>
<rect x="578" y="759" width="11" height="11" rx="2" fill="#10B981" fill-opacity="0.25" stroke="#10B981" stroke-width="1"/>
<text x="594" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#94A3B8">Execution (Stage 0)</text>
<rect x="726" y="759" width="11" height="11" rx="2" fill="#A78BFA" fill-opacity="0.25" stroke="#A78BFA" stroke-width="1"/>
<text x="742" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#94A3B8">State (SurrealDB)</text>
<line x1="852" y1="765" x2="875" y2="765" stroke="#F43F5E" stroke-width="2" stroke-dasharray="5,3"/>
<text x="882" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#94A3B8">Rollback path</text>
<text x="1588" y="775" font-family="'Inter',sans-serif" font-size="8" fill="#374151" text-anchor="end">StratumIOps · stratum-orchestrator</text>
<text x="1588" y="789" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#374151" text-anchor="end">stratum-orchestrator · build pipeline</text>
</svg>

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -0,0 +1,469 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1170 830">
<defs>
<style>
.flow { animation: flow-a 3s linear infinite; }
.flow-fast { animation: flow-a 1.8s linear infinite; }
.flow-slow { animation: flow-a 5s linear infinite; }
.flow-vslow { animation: flow-a 7s linear infinite; }
@keyframes flow-a { from { stroke-dashoffset: 24; } to { stroke-dashoffset: 0; } }
.orbit { animation: orbit-a 8s linear infinite; }
@keyframes orbit-a { from { stroke-dashoffset: 0; } to { stroke-dashoffset: -68; } }
.glow-pulse { animation: glow-a 3s ease-in-out infinite; }
@keyframes glow-a { 0%,100% { opacity:.08; } 50% { opacity:.28; } }
.hb { animation: hb-a 3s ease-in-out infinite; }
.hb-d1 { animation-delay: .4s; }
.hb-d2 { animation-delay: .8s; }
.hb-d3 { animation-delay:1.2s; }
.hb-d4 { animation-delay:1.6s; }
.hb-d5 { animation-delay:2.0s; }
@keyframes hb-a { 0%,100% { opacity:.85; } 50% { opacity:1; } }
.particle-spin1 { animation: spin1 6s linear infinite; }
.particle-spin2 { animation: spin2 9s linear infinite; }
.particle-spin3 { animation: spin3 14s linear infinite; }
@keyframes spin1 { from { transform: rotate(0deg) translateX(120px); } to { transform: rotate(360deg) translateX(120px); } }
@keyframes spin2 { from { transform: rotate(120deg) translateX(120px); } to { transform: rotate(480deg) translateX(120px); } }
@keyframes spin3 { from { transform: rotate(240deg) translateX(120px); } to { transform: rotate(600deg) translateX(120px); } }
</style>
<radialGradient id="bg-grad" cx="50%" cy="42%" r="58%">
<stop offset="0%" stop-color="#F0F4F8"/>
<stop offset="100%" stop-color="#E2EAF4"/>
</radialGradient>
<linearGradient id="title-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#C7D2FE"/>
<stop offset="50%" stop-color="#E0E7FF"/>
<stop offset="100%" stop-color="#C7D2FE"/>
</linearGradient>
<linearGradient id="orch-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#EEF2FF"/>
<stop offset="100%" stop-color="#E0E7FF"/>
</linearGradient>
<linearGradient id="auth-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFFBEB"/>
<stop offset="100%" stop-color="#FEF9C3"/>
</linearGradient>
<linearGradient id="surreal-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F5F3FF"/>
<stop offset="100%" stop-color="#EDE9FE"/>
</linearGradient>
<linearGradient id="vault-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFFBEB"/>
<stop offset="100%" stop-color="#FEF3C7"/>
</linearGradient>
<linearGradient id="oci-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ECFEFF"/>
<stop offset="100%" stop-color="#CFFAFE"/>
</linearGradient>
<linearGradient id="forgejo-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFF7ED"/>
<stop offset="100%" stop-color="#FFEDD5"/>
</linearGradient>
<linearGradient id="exec-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F0FDF4"/>
<stop offset="100%" stop-color="#DCFCE7"/>
</linearGradient>
<linearGradient id="agent-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F5F3FF"/>
<stop offset="100%" stop-color="#EDE9FE"/>
</linearGradient>
<linearGradient id="ncl-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#6366F1" stop-opacity=".18"/>
<stop offset="50%" stop-color="#06B6D4" stop-opacity=".12"/>
<stop offset="100%" stop-color="#6366F1" stop-opacity=".18"/>
</linearGradient>
<linearGradient id="event-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFF7ED"/>
<stop offset="100%" stop-color="#FFEDD5"/>
</linearGradient>
<linearGradient id="mod-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#EEF2FF"/>
<stop offset="100%" stop-color="#E8EDFF"/>
</linearGradient>
<linearGradient id="mod-ag-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F5F3FF"/>
<stop offset="100%" stop-color="#EDE9FE"/>
</linearGradient>
<linearGradient id="mod-pc-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ECFEFF"/>
<stop offset="100%" stop-color="#CFFAFE"/>
</linearGradient>
<linearGradient id="mod-sr-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F0FDF4"/>
<stop offset="100%" stop-color="#DCFCE7"/>
</linearGradient>
<linearGradient id="mod-re-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFFBEB"/>
<stop offset="100%" stop-color="#FEF3C7"/>
</linearGradient>
<linearGradient id="orch-stripe" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#6366F1"/>
<stop offset="100%" stop-color="#06B6D4"/>
</linearGradient>
<filter id="shadow" x="-15%" y="-15%" width="130%" height="130%">
<feDropShadow dx="0" dy="3" stdDeviation="8" flood-color="#94A3B8" flood-opacity=".3"/>
</filter>
<filter id="glow-c" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<marker id="arr-cyan" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#0891B2"/></marker>
<marker id="arr-gold" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#D97706"/></marker>
<marker id="arr-green" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#059669"/></marker>
<marker id="arr-orange" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#EA6C00"/></marker>
<marker id="arr-purple" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#7C3AED"/></marker>
<marker id="arr-indigo" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#4F46E5"/></marker>
<marker id="arr-silver" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#64748B"/></marker>
<marker id="arr-dcyan" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#0891B2"/></marker>
<!-- ─── stratumiops-h logo defs ─── -->
<linearGradient id="hpLayerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#4F46E5"/>
</linearGradient>
<linearGradient id="hpProcessorGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#06B6D4"/>
</linearGradient>
<linearGradient id="hpFlowGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="0%;100%;0%" dur="2.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="100%;200%;100%" dur="2.5s" repeatCount="indefinite"/>
</linearGradient>
<linearGradient id="hpUnderlineGrad" x1="280" y1="195" x2="750" y2="195" gradientUnits="userSpaceOnUse">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
</linearGradient>
<linearGradient id="hpShimmer" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="40%" style="stop-color:#6366F1"/>
<stop offset="50%" style="stop-color:#22D3EE"/><stop offset="60%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="-100%;100%" dur="3s" repeatCount="indefinite" begin="2s"/>
<animate attributeName="x2" values="0%;200%" dur="3s" repeatCount="indefinite" begin="2s"/>
</linearGradient>
<filter id="hpProcessorGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="5" result="coloredBlur"><animate attributeName="stdDeviation" values="4;8;4" dur="1.5s" repeatCount="indefinite"/></feGaussianBlur>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="hpNodeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<clipPath id="hpTextReveal">
<rect x="280" y="80" width="0" height="150">
<animate attributeName="width" values="0;800" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
</clipPath>
<path id="hpPathIn1" d="M25 40 Q25 65 55 80 Q85 95 95 100" fill="none"/>
<path id="hpPathIn2" d="M215 40 Q215 65 185 80 Q155 95 145 100" fill="none"/>
<path id="hpPathOut1" d="M95 100 Q85 105 55 120 Q25 135 25 160" fill="none"/>
<path id="hpPathOut2" d="M145 100 Q155 105 185 120 Q215 135 215 160" fill="none"/>
</defs>
<!-- ─── BACKGROUND ─── -->
<rect width="1170" height="830" fill="url(#bg-grad)"/>
<g opacity=".06">
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M40 0L0 0 0 40" fill="none" stroke="#64748B" stroke-width=".5"/>
</pattern>
<rect width="1170" height="830" fill="url(#grid)"/>
</g>
<g opacity=".25">
<circle cx="90" cy="200" r=".8" fill="#94A3B8"/>
<circle cx="420" cy="88" r=".6" fill="#94A3B8"/>
<circle cx="980" cy="130" r="1" fill="#94A3B8"/>
<circle cx="55" cy="600" r=".5" fill="#94A3B8"/>
<circle cx="700" cy="800" r=".6" fill="#94A3B8"/>
</g>
<!-- ─── TITLE BAR ─── -->
<rect x="0" y="0" width="1170" height="60" fill="url(#title-grad)"/>
<line x1="0" y1="60" x2="1170" y2="60" stroke="#6366F1" stroke-width=".8" opacity=".5"/>
<text x="600" y="30" text-anchor="middle" fill="#1E40AF" font-family="'IBM Plex Mono',monospace" font-size="19" font-weight="700" letter-spacing="3" dominant-baseline="middle">STRATUM ORCHESTRATOR</text>
<text x="600" y="50" text-anchor="middle" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="9" letter-spacing="1.5">Event-Driven · Graph-Guided · Atomic Execution · Stateless · OCI-Native</text>
<!-- ─── EVENT SOURCES ROW ─── -->
<text x="400" y="82" text-anchor="middle" fill="#EA6C00" font-family="'IBM Plex Mono',monospace" font-size="8" letter-spacing="2" opacity=".9">EVENT SOURCES</text>
<rect x="55" y="90" width="128" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb"/>
<rect x="55" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="55" y="90" width="128" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="70" y="111" fill="#1E293B" font-family="Inter,sans-serif" font-size="11" font-weight="600">provisioning</text>
<text x="70" y="127" fill="#EA6C00" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".9">emits dev.crate.&gt;</text>
<text x="70" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">crate-modified · deploy</text>
<rect x="200" y="90" width="118" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d1"/>
<rect x="200" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="200" y="90" width="118" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="215" y="111" fill="#1E293B" font-family="Inter,sans-serif" font-size="11" font-weight="600">kogral</text>
<text x="215" y="127" fill="#EA6C00" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".9">emits dev.knowledge.&gt;</text>
<text x="215" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">node-updated · indexed</text>
<rect x="335" y="90" width="120" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d2"/>
<rect x="335" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="335" y="90" width="120" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="350" y="111" fill="#1E293B" font-family="Inter,sans-serif" font-size="11" font-weight="600">syntaxis</text>
<text x="350" y="127" fill="#EA6C00" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".9">emits dev.project.&gt;</text>
<text x="350" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">phase · task-completed</text>
<rect x="472" y="90" width="135" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d3"/>
<rect x="472" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="472" y="90" width="135" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="487" y="111" fill="#1E293B" font-family="Inter,sans-serif" font-size="11" font-weight="600">stratumiops</text>
<text x="487" y="127" fill="#EA6C00" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".9">emits dev.model.&gt;</text>
<text x="487" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">llm-call · embed-request</text>
<rect x="624" y="90" width="118" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d4"/>
<rect x="624" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="624" y="90" width="118" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="639" y="111" fill="#1E293B" font-family="Inter,sans-serif" font-size="11" font-weight="600">typedialog</text>
<text x="639" y="127" fill="#EA6C00" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".9">emits dev.form.&gt;</text>
<text x="639" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">submitted · validated</text>
<rect x="755" y="90" width="120" height="60" rx="6" fill="none" stroke="#94A3B8" stroke-width=".8" stroke-dasharray="4 3" opacity=".6"/>
<text x="815" y="127" text-anchor="middle" fill="#94A3B8" font-family="Inter,sans-serif" font-size="19">+ more ...</text>
<!-- ─── CONNECTIONS: PROJECTS → NATS ─── -->
<line x1="119" y1="150" x2="317" y2="262" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="259" y1="150" x2="337" y2="257" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="395" y1="150" x2="355" y2="256" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="539" y1="150" x2="374" y2="258" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="686" y1="150" x2="396" y2="263" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<!-- ─── NATS ORBITAL RING ─── -->
<circle cx="355" cy="385" r="129" fill="none" stroke="#0891B2" stroke-width="14" opacity=".05" class="glow-pulse"/>
<circle cx="355" cy="385" r="123" fill="none" stroke="#0891B2" stroke-width="10" opacity=".08" class="glow-pulse" filter="url(#glow-c)"/>
<circle cx="355" cy="385" r="120" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-dasharray="10 7" class="orbit"/>
<circle cx="355" cy="385" r="66" fill="none" stroke="#6366F1" stroke-width=".5" opacity=".3"/>
<radialGradient id="nats-inner" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#0891B2" stop-opacity=".14"/>
<stop offset="100%" stop-color="#6366F1" stop-opacity=".06"/>
</radialGradient>
<circle cx="355" cy="385" r="66" fill="url(#nats-inner)"/>
<text x="355" y="379" text-anchor="middle" fill="#0E7490" font-family="'IBM Plex Mono',monospace" font-size="20" font-weight="700" filter="url(#glow-c)">NATS</text>
<text x="355" y="398" text-anchor="middle" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="9">JetStream</text>
<text x="344" y="235" text-anchor="middle" fill="#0E7490" font-family="'IBM Plex Mono',monospace" font-size="9" opacity=".8">dev.&gt;</text>
<g transform="translate(355,385)">
<circle r="4" fill="#0891B2" opacity=".9" class="particle-spin1"/>
<circle r="3.5" fill="#6366F1" opacity=".7" class="particle-spin2"/>
<circle r="3" fill="#F97316" opacity=".5" class="particle-spin3"/>
</g>
<!-- ─── NATS → ORCHESTRATOR ─── -->
<path d="M 475,385 L 495,385" stroke="#0891B2" stroke-width="3" stroke-dasharray="8 4" class="flow-fast" marker-end="url(#arr-cyan)" filter="url(#glow-c)"/>
<circle r="4" fill="#0891B2" opacity=".85">
<animateMotion dur="0.25s" repeatCount="indefinite" path="M 475,385 L 495,385"/>
</circle>
<circle r="3" fill="#6366F1" opacity=".7">
<animateMotion dur="0.25s" repeatCount="indefinite" begin="0.08s" path="M 475,385 L 495,385"/>
</circle>
<!-- NKey verify badge -->
<rect x="379" y="419" width="78" height="22" rx="3" fill="#FFFBEB" stroke="#D97706" stroke-width=".7" opacity=".9"/>
<text x="418" y="429" text-anchor="middle" fill="#D97706" font-family="'IBM Plex Mono',monospace" font-size="7">NKey verify</text>
<text x="418" y="439" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="6.5">ed25519 JWT</text>
<!-- ─── ORCHESTRATOR BOX ─── -->
<rect x="498" y="250" width="375" height="300" rx="10" fill="#CBD5E1" opacity=".25" transform="translate(3,5)"/>
<rect x="498" y="250" width="375" height="300" rx="10" fill="url(#orch-grad)" filter="url(#shadow)" class="hb"/>
<rect x="498" y="250" width="375" height="300" rx="10" fill="none" stroke="#6366F1" stroke-width=".8" opacity=".5" class="glow-pulse"/>
<rect x="498" y="250" width="4" height="300" rx="2" fill="url(#orch-stripe)"/>
<text x="685" y="273" text-anchor="middle" fill="#1E293B" font-family="Inter,sans-serif" font-size="14" font-weight="700">STRATUM ORCHESTRATOR</text>
<text x="685" y="290" text-anchor="middle" fill="#475569" font-family="Inter,sans-serif" font-size="9">Agnostic · Stateless · Graph-Guided</text>
<!-- Internal modules 2×2 -->
<!-- ActionGraph -->
<rect x="510" y="305" width="170" height="72" rx="6" fill="url(#mod-ag-grad)" stroke="#4F46E5" stroke-width=".7" opacity=".9"/>
<text x="522" y="325" fill="#4338CA" font-family="Inter,sans-serif" font-size="11" font-weight="600">◈ ActionGraph</text>
<text x="522" y="341" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">in-memory · Nickel nodes</text>
<text x="522" y="355" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">topo-sort · cycle-detect</text>
<!-- PipelineContext -->
<rect x="690" y="305" width="171" height="72" rx="6" fill="url(#mod-pc-grad)" stroke="#06B6D4" stroke-width=".7" opacity=".9"/>
<text x="702" y="325" fill="#4338CA" font-family="Inter,sans-serif" font-size="11" font-weight="600">⬡ PipelineCtx</text>
<text x="702" y="341" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">DB-first · typed caps</text>
<text x="702" y="355" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">schema-validated</text>
<!-- StageRunner -->
<rect x="510" y="389" width="170" height="130" rx="6" fill="url(#mod-sr-grad)" stroke="#059669" stroke-width=".7" opacity=".9"/>
<text x="522" y="407" fill="#4338CA" font-family="Inter,sans-serif" font-size="11" font-weight="600">▶ StageRunner</text>
<text x="522" y="423" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">JoinSet · parallel stages</text>
<text x="522" y="437" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">CancellationToken</text>
<text x="522" y="451" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">retry + backoff on failure</text>
<text x="522" y="465" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">saga compensate.nu</text>
<!-- RuleEngine -->
<rect x="690" y="389" width="171" height="130" rx="6" fill="url(#mod-re-grad)" stroke="#D97706" stroke-width=".7" opacity=".9"/>
<text x="702" y="407" fill="#4338CA" font-family="Inter,sans-serif" font-size="11" font-weight="600">⚙ RuleEngine</text>
<rect x="810" y="393" width="44" height="13" rx="3" fill="#FFFBEB" stroke="#D97706" stroke-width=".6"/>
<text x="832" y="402" text-anchor="middle" fill="#D97706" font-family="'IBM Plex Mono',monospace" font-size="7">Cedar</text>
<line x1="702" y1="415" x2="854" y2="415" stroke="#4F46E5" stroke-width=".4" opacity=".4"/>
<text x="702" y="428" fill="#D97706" font-family="'IBM Plex Mono',monospace" font-size="8" font-weight="600">◈ Cedar</text>
<text x="702" y="441" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">permit · forbid · conditions</text>
<text x="702" y="454" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">per-node authz policies</text>
<line x1="702" y1="463" x2="854" y2="463" stroke="#4F46E5" stroke-width=".4" opacity=".4"/>
<text x="702" y="476" fill="#D97706" font-family="'IBM Plex Mono',monospace" font-size="8" font-weight="600">◈ NKey</text>
<text x="702" y="489" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">ed25519 asymmetric keys</text>
<text x="702" y="502" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">JWT per-process · verify</text>
<text x="685" y="538" text-anchor="middle" fill="#DC2626" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".8">↺ Saga rollback on failure · compensate.nu in reverse</text>
<!-- ─── RIGHT PANEL: DATA STORES ─── -->
<path d="M 873,256 L 926,216" stroke="#7C3AED" stroke-width="1.5" stroke-dasharray="4 3" class="flow" marker-end="url(#arr-purple)" fill="none" opacity=".8"/>
<path d="M 873,350 L 926,310" stroke="#D97706" stroke-width="1.5" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-gold)" fill="none" opacity=".8"/>
<path d="M 873,444 L 926,404" stroke="#0891B2" stroke-width="1" stroke-dasharray="3 4" class="flow-slow" marker-end="url(#arr-dcyan)" fill="none" opacity=".7"/>
<!-- SurrealDB -->
<rect x="926" y="175" width="197" height="82" rx="8" fill="url(#surreal-grad)" filter="url(#shadow)" class="hb hb-d2"/>
<rect x="926" y="175" width="3" height="82" rx="1" fill="#7C3AED"/>
<rect x="926" y="175" width="197" height="82" rx="8" fill="none" stroke="#7C3AED" stroke-width=".8" opacity=".5"/>
<text x="941" y="198" fill="#7C3AED" font-family="Inter,sans-serif" font-size="12" font-weight="700">SurrealDB</text>
<text x="941" y="215" fill="#475569" font-family="Inter,sans-serif" font-size="9">Pipeline state · Step results</text>
<text x="941" y="231" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">orchestrator_state ns</text>
<text x="941" y="247" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">crash recovery</text>
<!-- SecretumVault -->
<rect x="926" y="269" width="197" height="82" rx="8" fill="url(#vault-grad)" filter="url(#shadow)" class="hb hb-d3"/>
<rect x="926" y="269" width="3" height="82" rx="1" fill="#D97706"/>
<rect x="926" y="269" width="197" height="82" rx="8" fill="none" stroke="#D97706" stroke-width=".8" opacity=".5"/>
<text x="941" y="292" fill="#D97706" font-family="Inter,sans-serif" font-size="12" font-weight="700">SecretumVault</text>
<text x="941" y="309" fill="#475569" font-family="Inter,sans-serif" font-size="9">Credentials · TTL leases</text>
<text x="941" y="325" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">vault:/secret/...</text>
<text x="941" y="341" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">never in NATS payload</text>
<!-- Zot OCI -->
<rect x="926" y="363" width="197" height="82" rx="8" fill="url(#oci-grad)" filter="url(#shadow)" class="hb hb-d4"/>
<rect x="926" y="363" width="3" height="82" rx="1" fill="#0891B2"/>
<rect x="926" y="363" width="197" height="82" rx="8" fill="none" stroke="#0891B2" stroke-width=".8" opacity=".5"/>
<text x="941" y="386" fill="#0891B2" font-family="Inter,sans-serif" font-size="12" font-weight="700">Zot OCI Registry</text>
<text x="941" y="403" fill="#475569" font-family="Inter,sans-serif" font-size="9">Node defs · Nickel libs</text>
<text x="941" y="419" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">oci://registry/nodes/</text>
<text x="941" y="435" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">content-addressed · signed</text>
<!-- ─── RIGHT PANEL: OPTIONAL SERVICES ─── -->
<text x="1042" y="533" text-anchor="middle" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="8" letter-spacing="2">OPTIONAL</text>
<path d="M 873,495 C 903,495 903,559 926,559" stroke="#94A3B8" stroke-width="1" stroke-dasharray="3 5" class="flow-vslow" marker-end="url(#arr-silver)" fill="none" opacity=".6"/>
<path d="M 873,510 C 905,510 905,653 926,653" stroke="#94A3B8" stroke-width="1" stroke-dasharray="3 5" class="flow-vslow" marker-end="url(#arr-silver)" fill="none" opacity=".6"/>
<path d="M 873,525 C 907,525 907,744 926,744" stroke="#94A3B8" stroke-width="1" stroke-dasharray="3 5" class="flow-vslow" marker-end="url(#arr-silver)" fill="none" opacity=".5"/>
<!-- Kogral -->
<rect x="926" y="518" width="197" height="82" rx="8" fill="#F8FAFC" stroke="#CBD5E1" stroke-width=".8" stroke-dasharray="5 4" opacity=".9" class="hb hb-d2"/>
<text x="941" y="541" fill="#475569" font-family="Inter,sans-serif" font-size="12" font-weight="600">Kogral</text>
<text x="941" y="558" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Knowledge graph</text>
<text x="941" y="574" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="8">node-updated triggers</text>
<rect x="1071" y="522" width="46" height="16" rx="3" fill="#E2E8F0"/>
<text x="1094" y="533" text-anchor="middle" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="7">optional</text>
<!-- Syntaxis -->
<rect x="926" y="612" width="197" height="82" rx="8" fill="#F8FAFC" stroke="#CBD5E1" stroke-width=".8" stroke-dasharray="5 4" opacity=".9" class="hb hb-d3"/>
<text x="941" y="635" fill="#475569" font-family="Inter,sans-serif" font-size="12" font-weight="600">Syntaxis</text>
<text x="941" y="652" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Project orchestration</text>
<text x="941" y="668" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="8">phase-transition events</text>
<rect x="1071" y="616" width="46" height="16" rx="3" fill="#E2E8F0"/>
<text x="1094" y="627" text-anchor="middle" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="7">optional</text>
<!-- TypeDialog -->
<rect x="926" y="706" width="197" height="75" rx="8" fill="#F8FAFC" stroke="#CBD5E1" stroke-width=".8" stroke-dasharray="5 4" opacity=".8" class="hb hb-d4"/>
<text x="941" y="729" fill="#475569" font-family="Inter,sans-serif" font-size="12" font-weight="600">TypeDialog</text>
<text x="941" y="746" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Service config UI</text>
<text x="941" y="764" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="8">startup config NCL only</text>
<!-- ─── LEFT: Git repo ─── -->
<path d="M 498,545 C 120,650 50,350 180,337" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" fill="none" opacity=".6"/>
<path d="M 180,337 L 230,353" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" fill="none" opacity=".7"/>
<rect x="30" y="295" width="150" height="85" rx="8" fill="url(#forgejo-grad)" filter="url(#shadow)" class="hb hb-d5"/>
<rect x="30" y="295" width="3" height="85" rx="1" fill="#F97316"/>
<rect x="30" y="295" width="150" height="85" rx="8" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="45" y="318" fill="#EA6C00" font-family="Inter,sans-serif" font-size="12" font-weight="700">Git repo</text>
<text x="45" y="335" fill="#475569" font-family="Inter,sans-serif" font-size="9">Git events → NATS</text>
<text x="45" y="351" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">webhook → dev.crate.&gt;</text>
<text x="45" y="367" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">push · tag · pr</text>
<!-- ─── EXECUTION LAYER ─── -->
<path d="M 518,550 C 500,560 485,610 483,614" stroke="#059669" stroke-width="1.5" stroke-dasharray="5 3" class="flow" marker-end="url(#arr-green)" fill="none" opacity=".8"/>
<path d="M 773,550 C 755,560 717,610 714,616" stroke="#4F46E5" stroke-width="1.5" stroke-dasharray="5 3" class="flow" marker-end="url(#arr-indigo)" fill="none" opacity=".8"/>
<rect x="380" y="623" width="200" height="80" rx="8" fill="url(#exec-grad)" filter="url(#shadow)" class="hb"/>
<rect x="380" y="623" width="3" height="80" rx="1" fill="#059669"/>
<rect x="380" y="623" width="200" height="80" rx="8" fill="none" stroke="#059669" stroke-width=".8" opacity=".5"/>
<text x="395" y="644" fill="#059669" font-family="Inter,sans-serif" font-size="12" font-weight="700">Nu Executor</text>
<text x="395" y="660" fill="#475569" font-family="Inter,sans-serif" font-size="9">Atomic steps · Pure functions</text>
<text x="395" y="675" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">scripts/nu/*.nu</text>
<text x="395" y="689" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">stdout=output · exit-code=status</text>
<rect x="615" y="623" width="200" height="80" rx="8" fill="url(#agent-grad)" filter="url(#shadow)" class="hb hb-d1"/>
<rect x="615" y="623" width="3" height="80" rx="1" fill="#4F46E5"/>
<rect x="615" y="623" width="200" height="80" rx="8" fill="none" stroke="#4F46E5" stroke-width=".8" opacity=".5"/>
<text x="630" y="644" fill="#4F46E5" font-family="Inter,sans-serif" font-size="12" font-weight="700">AI Agent</text>
<text x="630" y="660" fill="#475569" font-family="Inter,sans-serif" font-size="9">stratum-llm · NATS protocol</text>
<text x="630" y="675" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">dev.agent.*.requested/responded</text>
<text x="630" y="689" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">oneshot correlation · timeout</text>
<!-- ─── NICKEL BASE LIBRARY ─── -->
<rect x="390" y="738" width="415" height="65" rx="8" fill="#F0F4FF" stroke="none"/>
<rect x="390" y="738" width="415" height="65" rx="8" fill="url(#ncl-grad)"/>
<rect x="390" y="738" width="415" height="65" rx="8" fill="none" stroke="#6366F1" stroke-width=".8" stroke-dasharray="6 4" opacity=".6"/>
<text x="597" y="761" text-anchor="middle" fill="#4338CA" font-family="Inter,sans-serif" font-size="12" font-weight="600">Nickel Base Library</text>
<text x="597" y="777" text-anchor="middle" fill="#475569" font-family="Inter,sans-serif" font-size="9">OCI-published · content-addressed · build-verified · typecheck-gated</text>
<text x="597" y="791" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">orchestrator-types.ncl · capability-schemas.ncl · defaults.ncl</text>
<!-- Zot OCI → Nickel -->
<path d="M 926,414 C 900,390 900,750 805,770" stroke="#0891B2" stroke-width="1" stroke-dasharray="3 3" class="flow-slow" marker-end="url(#arr-dcyan)" fill="none" opacity=".6"/>
<!-- ─── LOGO above LEGEND ─── -->
<g transform="translate(8,5) scale(0.2)">
<g transform="translate(20,50)">
<rect x="-220" y="18" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.5;0.8;0.5" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<rect x="-220" y="88" width="220" height="24" rx="5" fill="url(#hpLayerGrad)">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.15s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
<rect x="-220" y="158" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.3s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.8;0.5;0.8" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<path d="M35 42 Q35 65 70 85 Q95 100 95 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.5s"/></path>
<path d="M185 42 Q185 65 150 85 Q125 100 125 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.6s"/></path>
<path d="M95 100 Q95 100 70 115 Q35 135 35 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.7s"/></path>
<path d="M125 100 Q125 100 150 115 Q185 135 185 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.8s"/></path>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.5s"><mpath href="#hpPathIn1"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.8s"><mpath href="#hpPathIn2"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.1s"><mpath href="#hpPathOut1"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.1s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.4s"><mpath href="#hpPathOut2"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.4s"/></circle>
<circle cx="35" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.25s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="185" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.3s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="35" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.4s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle cx="185" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.45s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<rect x="85" y="75" width="50" height="50" rx="9" fill="url(#hpProcessorGrad)" filter="url(#hpProcessorGlow)" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.3s" fill="freeze" begin="0.45s"/></rect>
<rect x="97" y="87" width="26" height="26" rx="5" fill="#ffffff" opacity="0"><animate attributeName="opacity" values="0;0.95" dur="0.25s" fill="freeze" begin="0.55s"/></rect>
<rect x="101" y="96" width="4" height="8" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.7s"/><animate attributeName="height" values="8;14;6;10;8" dur="0.6s" repeatCount="indefinite" begin="1.2s"/><animate attributeName="y" values="96;93;97;95;96" dur="0.6s" repeatCount="indefinite" begin="1.2s"/></rect>
<rect x="108" y="93" width="4" height="14" rx="1" fill="#06B6D4" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.75s"/><animate attributeName="height" values="14;8;12;16;14" dur="0.55s" repeatCount="indefinite" begin="1.25s"/><animate attributeName="y" values="93;96;94;92;93" dur="0.55s" repeatCount="indefinite" begin="1.25s"/></rect>
<rect x="115" y="95" width="4" height="10" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.8s"/><animate attributeName="height" values="10;16;8;12;10" dur="0.5s" repeatCount="indefinite" begin="1.3s"/><animate attributeName="y" values="95;92;96;94;95" dur="0.5s" repeatCount="indefinite" begin="1.3s"/></rect>
<rect x="85" y="75" width="50" height="50" rx="9" fill="none" stroke="#22D3EE" stroke-width="1.5" opacity="0"><animate attributeName="x" values="85;70" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="y" values="75;60" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="width" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="height" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="rx" values="9;14" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="opacity" values="0.5;0" dur="2s" repeatCount="indefinite" begin="1.5s"/></rect>
</g>
<g clip-path="url(#hpTextReveal)">
<text x="280" y="175" font-family="'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-weight="700" font-size="72" fill="url(#hpShimmer)">Stratum<tspan fill="url(#hpProcessorGrad)">I</tspan>Ops</text>
</g>
<rect x="280" y="115" width="4" height="70" rx="2" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1;1;0;0" dur="0.8s" fill="freeze" begin="1s" keyTimes="0;0.1;0.5;0.51;1"/><animate attributeName="x" values="280;980" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></rect>
<line x1="280" y1="195" x2="280" y2="195" stroke="url(#hpUnderlineGrad)" stroke-width="3" stroke-linecap="round" opacity="0"><animate attributeName="opacity" values="0;0.7" dur="0.1s" fill="freeze" begin="1.4s"/><animate attributeName="x2" values="280;750" dur="0.8s" fill="freeze" begin="1.4s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></line>
</g>
<!-- ─── LEGEND ─── -->
<rect x="45" y="718" width="270" height="88" rx="6" fill="#F8FAFC" stroke="#CBD5E1" stroke-width=".8"/>
<text x="180" y="736" text-anchor="middle" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="8" letter-spacing="2">LEGEND</text>
<line x1="55" y1="750" x2="85" y2="750" stroke="#0891B2" stroke-width="2" stroke-dasharray="6 3"/>
<text x="91" y="754" fill="#475569" font-family="Inter,sans-serif" font-size="9">event flow (NATS)</text>
<line x1="55" y1="768" x2="85" y2="768" stroke="#D97706" stroke-width="2" stroke-dasharray="6 3"/>
<text x="91" y="772" fill="#475569" font-family="Inter,sans-serif" font-size="9">auth / credentials</text>
<line x1="55" y1="786" x2="85" y2="786" stroke="#059669" stroke-width="2" stroke-dasharray="6 3"/>
<text x="91" y="790" fill="#475569" font-family="Inter,sans-serif" font-size="9">execution</text>
<line x1="213" y1="750" x2="243" y2="750" stroke="#7C3AED" stroke-width="2" stroke-dasharray="6 3"/>
<text x="249" y="754" fill="#475569" font-family="Inter,sans-serif" font-size="9">state (DB)</text>
<line x1="213" y1="768" x2="243" y2="768" stroke="#0891B2" stroke-width="2" stroke-dasharray="6 3"/>
<text x="249" y="772" fill="#475569" font-family="Inter,sans-serif" font-size="9">OCI / registry</text>
<line x1="213" y1="786" x2="243" y2="786" stroke="#94A3B8" stroke-width="1.5" stroke-dasharray="3 5"/>
<text x="249" y="790" fill="#475569" font-family="Inter,sans-serif" font-size="9">optional</text>
<!-- ─── BRANDING ─── -->
<text x="1140" y="817" text-anchor="end" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="9">stratumiops · v0.1</text>
</svg>

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -0,0 +1,450 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1600 820" width="1600" height="820">
<defs>
<filter id="glow-c" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="glow-g" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="glow-i" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<marker id="ac" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#22D3EE"/>
</marker>
<marker id="ai" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#6366F1"/>
</marker>
<marker id="ag" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#F59E0B"/>
</marker>
<marker id="agr" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#10B981"/>
</marker>
<marker id="ap" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#A78BFA"/>
</marker>
<marker id="ar" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#F43F5E"/>
</marker>
<marker id="aib" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#818CF8"/>
</marker>
<marker id="ail" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#A5B4FC"/>
</marker>
<marker id="ao" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#F97316"/>
</marker>
<!-- ─── stratumiops-h logo defs ─── -->
<linearGradient id="hpLayerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#4F46E5"/>
</linearGradient>
<linearGradient id="hpProcessorGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#06B6D4"/>
</linearGradient>
<linearGradient id="hpFlowGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="0%;100%;0%" dur="2.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="100%;200%;100%" dur="2.5s" repeatCount="indefinite"/>
</linearGradient>
<linearGradient id="hpUnderlineGrad" x1="280" y1="195" x2="750" y2="195" gradientUnits="userSpaceOnUse">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
</linearGradient>
<linearGradient id="hpShimmer" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="40%" style="stop-color:#6366F1"/>
<stop offset="50%" style="stop-color:#22D3EE"/><stop offset="60%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="-100%;100%" dur="3s" repeatCount="indefinite" begin="2s"/>
<animate attributeName="x2" values="0%;200%" dur="3s" repeatCount="indefinite" begin="2s"/>
</linearGradient>
<filter id="hpProcessorGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="5" result="coloredBlur"><animate attributeName="stdDeviation" values="4;8;4" dur="1.5s" repeatCount="indefinite"/></feGaussianBlur>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="hpNodeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<clipPath id="hpTextReveal">
<rect x="280" y="80" width="0" height="150">
<animate attributeName="width" values="0;800" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
</clipPath>
<path id="hpPathIn1" d="M25 40 Q25 65 55 80 Q85 95 95 100" fill="none"/>
<path id="hpPathIn2" d="M215 40 Q215 65 185 80 Q155 95 145 100" fill="none"/>
<path id="hpPathOut1" d="M95 100 Q85 105 55 120 Q25 135 25 160" fill="none"/>
<path id="hpPathOut2" d="M145 100 Q155 105 185 120 Q215 135 215 160" fill="none"/>
</defs>
<style>
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.65} }
@keyframes nats-glow { 0%,100%{stroke-opacity:1;filter:url(#glow-c)} 50%{stroke-opacity:0.5;filter:none} }
@keyframes auth-glow { 0%,100%{stroke-opacity:1;filter:url(#glow-g)} 50%{stroke-opacity:0.4;filter:none} }
@keyframes flow-dash { to{stroke-dashoffset:-20} }
@keyframes flow-dash-slow { to{stroke-dashoffset:-20} }
.fd { animation: flow-dash 1.2s linear infinite; }
.fds { animation: flow-dash-slow 2s linear infinite; }
.nd { animation: nats-glow 1.8s ease-in-out infinite; }
.ah { animation: auth-glow 2s ease-in-out infinite; }
.p1 { animation: pulse 2.4s ease-in-out 0s infinite; }
.p2 { animation: pulse 2.4s ease-in-out 0.2s infinite; }
.p3 { animation: pulse 2.4s ease-in-out 0.4s infinite; }
.p4 { animation: pulse 2.4s ease-in-out 0.6s infinite; }
.p5 { animation: pulse 2.4s ease-in-out 0.8s infinite; }
.p6 { animation: pulse 2.4s ease-in-out 1.0s infinite; }
.p7 { animation: pulse 2.4s ease-in-out 1.2s infinite; }
.p8 { animation: pulse 2.4s ease-in-out 1.4s infinite; }
.p9 { animation: pulse 2.4s ease-in-out 1.6s infinite; }
.p10 { animation: pulse 2.4s ease-in-out 1.8s infinite; }
.p11 { animation: pulse 2.4s ease-in-out 0.3s infinite; }
.p12 { animation: pulse 2.4s ease-in-out 0.5s infinite; }
</style>
<!-- Background -->
<rect width="1600" height="820" fill="#F8FAFC"/>
<!-- TITLE BAR -->
<rect x="0" y="0" width="1600" height="2" fill="#22D3EE"/>
<rect x="0" y="0" width="1600" height="99" fill="#F1F5F9"/>
<text x="800" y="40" font-family="'IBM Plex Mono',monospace" font-size="19" fill="#0891B2" text-anchor="middle" font-weight="500" letter-spacing="2">STRATUM ORCHESTRATOR · BUILD PIPELINE FLOW</text>
<text x="800" y="66" font-family="'IBM Plex Mono',monospace" font-size="12" fill="#475569" text-anchor="middle">event: dev.crate.provisioning-cli.modified · trigger → ActionGraph → stages → state → emit</text>
<rect x="0" y="98" width="1600" height="2" fill="#E2E8F0"/>
<!-- LANE LABEL COLUMN SEPARATOR -->
<line x1="182" y1="100" x2="182" y2="742" stroke="#CBD5E1" stroke-width="2"/>
<!-- Lane 0: Developer / Forgejo -->
<rect x="0" y="100" width="6" height="80" fill="#F97316"/>
<rect x="6" y="100" width="176" height="80" fill="#F97316" fill-opacity="0.12"/>
<text x="22" y="131" font-family="'Inter',sans-serif" font-size="10" fill="#F97316" font-weight="600">Developer · Forgejo</text>
<text x="22" y="147" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">git push / webhook</text>
<!-- Lane 1: NATS JetStream -->
<rect x="0" y="180" width="6" height="80" fill="#22D3EE"/>
<rect x="6" y="180" width="176" height="80" fill="#22D3EE" fill-opacity="0.10"/>
<text x="22" y="211" font-family="'Inter',sans-serif" font-size="10" fill="#22D3EE" font-weight="600">NATS JetStream</text>
<text x="22" y="227" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">Event Bus</text>
<!-- Lane 2: Orchestrator Planning -->
<rect x="0" y="260" width="6" height="80" fill="#6366F1"/>
<rect x="6" y="260" width="176" height="80" fill="#6366F1" fill-opacity="0.10"/>
<text x="22" y="291" font-family="'Inter',sans-serif" font-size="10" fill="#6366F1" font-weight="600">Orchestrator</text>
<text x="22" y="307" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">Planning</text>
<!-- Lane 3: Auth -->
<rect x="0" y="340" width="6" height="80" fill="#F59E0B"/>
<rect x="6" y="340" width="176" height="80" fill="#F59E0B" fill-opacity="0.10"/>
<text x="22" y="371" font-family="'Inter',sans-serif" font-size="10" fill="#F59E0B" font-weight="600">Auth · NKeys</text>
<text x="22" y="387" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">+ Cedar</text>
<!-- Lane 4: Stage 0 Parallel -->
<rect x="0" y="420" width="6" height="80" fill="#10B981"/>
<rect x="6" y="420" width="176" height="80" fill="#10B981" fill-opacity="0.10"/>
<text x="22" y="451" font-family="'Inter',sans-serif" font-size="10" fill="#10B981" font-weight="600">Stage 0</text>
<text x="22" y="467" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">‖ Parallel</text>
<!-- Lane 5: Stage 1 -->
<rect x="0" y="500" width="6" height="80" fill="#818CF8"/>
<rect x="6" y="500" width="176" height="80" fill="#818CF8" fill-opacity="0.08"/>
<text x="22" y="531" font-family="'Inter',sans-serif" font-size="10" fill="#818CF8" font-weight="600">Stage 1</text>
<text x="22" y="547" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">→ Sequential</text>
<!-- Lane 6: Stage 2 -->
<rect x="0" y="580" width="6" height="80" fill="#A5B4FC"/>
<rect x="6" y="580" width="176" height="80" fill="#A5B4FC" fill-opacity="0.07"/>
<text x="22" y="611" font-family="'Inter',sans-serif" font-size="10" fill="#A5B4FC" font-weight="600">Stage 2</text>
<text x="22" y="627" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">→ Sequential</text>
<!-- Lane 7: SurrealDB -->
<rect x="0" y="660" width="6" height="80" fill="#A78BFA"/>
<rect x="6" y="660" width="176" height="80" fill="#A78BFA" fill-opacity="0.10"/>
<text x="22" y="691" font-family="'Inter',sans-serif" font-size="10" fill="#A78BFA" font-weight="600">SurrealDB</text>
<text x="22" y="707" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">State</text>
<!-- LANE DIVIDERS (content area) -->
<line x1="182" y1="180" x2="1590" y2="180" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="260" x2="1590" y2="260" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="340" x2="1590" y2="340" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="420" x2="1590" y2="420" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="500" x2="1590" y2="500" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="580" x2="1590" y2="580" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="660" x2="1590" y2="660" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="740" x2="1590" y2="740" stroke="#CBD5E1" stroke-width="1"/>
<!-- TIMELINE BASELINE (dashed, very subtle) -->
<path id="tl" d="M200,140 L1560,140" fill="none" stroke="#F97316" stroke-width="1" stroke-dasharray="5,4" opacity="0.2" class="fd"/>
<!-- ANIMATED PARTICLES along timeline -->
<circle r="5" fill="#22D3EE" opacity="0.85" filter="url(#glow-c)">
<animateMotion dur="6s" repeatCount="indefinite" begin="0s">
<mpath xlink:href="#tl"/>
</animateMotion>
</circle>
<circle r="4" fill="#6366F1" opacity="0.7">
<animateMotion dur="6s" repeatCount="indefinite" begin="2s">
<mpath xlink:href="#tl"/>
</animateMotion>
</circle>
<circle r="3.5" fill="#F59E0B" opacity="0.65">
<animateMotion dur="6s" repeatCount="indefinite" begin="4s">
<mpath xlink:href="#tl"/>
</animateMotion>
</circle>
<!-- ========== STEP 1: git push (Developer, x=220, yc=140) ========== -->
<g class="p1">
<circle cx="220" cy="112" r="9" fill="#F97316"/>
<text x="220" y="116" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">1</text>
<rect x="185" y="120" width="70" height="40" rx="5" fill="#F97316" fill-opacity="0.18" stroke="#F97316" stroke-width="1.5"/>
<text x="220" y="136" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F97316" text-anchor="middle" font-weight="500">git push</text>
<text x="220" y="150" font-family="'Inter',sans-serif" font-size="8" fill="#64748B" text-anchor="middle">code change</text>
<text x="220" y="170" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#334155" text-anchor="middle">commit: abc123f</text>
</g>
<!-- ========== STEP 2: webhook→NATS diamond (spans Developer/NATS border at y=180, x=330) ========== -->
<g class="nd p2">
<circle cx="330" cy="112" r="9" fill="#F97316"/>
<text x="330" y="116" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">2</text>
<polygon points="330,128 400,178 330,228 260,178" fill="#22D3EE" fill-opacity="0.12" stroke="#22D3EE" stroke-width="1.5"/>
<text x="330" y="163" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">dev.crate.</text>
<text x="330" y="178" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">provisioning-cli</text>
<text x="330" y="193" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">.modified</text>
<rect x="283" y="220" width="96" height="24" rx="3" fill="#F1F5F9" stroke="#CBD5E1" stroke-width="1"/>
<text x="330" y="231" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#334155" text-anchor="middle">&#123;"crate":"prvng-cli",</text>
<text x="330" y="241" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#334155" text-anchor="middle">"sha":"abc123f"&#125;</text>
</g>
<!-- ========== STEP 3: pull_batch(10) (Orchestrator, x=450, yc=300) ========== -->
<g class="p3">
<circle cx="450" cy="268" r="9" fill="#6366F1"/>
<text x="450" y="272" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">3</text>
<rect x="415" y="277" width="70" height="44" rx="5" fill="#6366F1" fill-opacity="0.18" stroke="#6366F1" stroke-width="1.5"/>
<text x="450" y="294" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#818CF8" text-anchor="middle" font-weight="500">pull_batch</text>
<text x="450" y="307" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#818CF8" text-anchor="middle">(10)</text>
<text x="450" y="332" font-family="'Inter',sans-serif" font-size="8" fill="#334155" text-anchor="middle">JetStream consumer</text>
</g>
<!-- ========== STEP 4: NKey verify (Auth hexagon, x=565, yc=382) ========== -->
<g class="ah p4">
<circle cx="565" cy="348" r="9" fill="#F59E0B"/>
<text x="565" y="352" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">4</text>
<polygon points="565,352 601,370 601,401 565,419 530,401 530,370" fill="#F59E0B" fill-opacity="0.12" stroke="#F59E0B" stroke-width="1.5"/>
<text x="565" y="381" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">NKey</text>
<text x="565" y="394" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">verify</text>
<text x="565" y="428" font-family="'Inter',sans-serif" font-size="8" fill="#334155" text-anchor="middle">publisher auth</text>
</g>
<!-- ========== STEP 5: ActionGraph query+topo-sort (Orchestrator, x=688, yc=299, wider) ========== -->
<g class="p5">
<circle cx="688" cy="268" r="9" fill="#6366F1"/>
<text x="688" y="272" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">5</text>
<rect x="644" y="277" width="88" height="46" rx="5" fill="#6366F1" fill-opacity="0.22" stroke="#6366F1" stroke-width="2" filter="url(#glow-i)"/>
<text x="688" y="293" font-family="'IBM Plex Mono',monospace" font-size="8.5" fill="#3730A3" text-anchor="middle" font-weight="600">ActionGraph</text>
<text x="688" y="306" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#4338CA" text-anchor="middle">query + topo-sort</text>
<!-- topo order annotation -->
<rect x="644" y="330" width="88" height="36" rx="3" fill="#F1F5F9" stroke="#CBD5E1" stroke-width="1"/>
<text x="688" y="341" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#334155" text-anchor="middle">[lint-crate, fmt-crate]</text>
<text x="688" y="351" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#334155" text-anchor="middle">→ build-crate</text>
<text x="688" y="361" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#334155" text-anchor="middle">→ install</text>
<!-- Stages bracket -->
<line x1="740" y1="277" x2="740" y2="502" stroke="#334155" stroke-width="1" stroke-dasharray="3,3"/>
<text x="754" y="396" font-family="'Inter',sans-serif" font-size="8" fill="#64748B" text-anchor="middle" transform="rotate(90,754,396)">Stages</text>
</g>
<!-- ========== STEP 6: Cedar authorize (Auth hexagon, x=800, yc=382) ========== -->
<g class="ah p6">
<circle cx="800" cy="348" r="9" fill="#F59E0B"/>
<text x="800" y="352" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">6</text>
<polygon points="800,352 836,370 836,401 800,419 765,401 765,370" fill="#F59E0B" fill-opacity="0.12" stroke="#F59E0B" stroke-width="1.5"/>
<text x="800" y="381" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">Cedar</text>
<text x="800" y="394" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">authorize</text>
<text x="800" y="425" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#334155" text-anchor="middle">permit: trigger</text>
<text x="800" y="435" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#334155" text-anchor="middle">node:build-crate</text>
</g>
<!-- ========== STEP 7: Stage 0 PARALLEL wide box (Stage 0, x=876-1036, yc=460) ========== -->
<!-- parallel bracket above -->
<path d="M878,428 L878,423 L1038,423 L1038,428" fill="none" stroke="#10B981" stroke-width="1.2" stroke-dasharray="3,2"/>
<text x="958" y="420" font-family="'Inter',sans-serif" font-size="9" fill="#10B981" text-anchor="middle" font-weight="600">‖ parallel · JoinSet</text>
<g class="p7">
<circle cx="878" cy="430" r="9" fill="#10B981"/>
<text x="878" y="434" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">7</text>
<!-- wide rect -->
<rect x="878" y="437" width="160" height="44" rx="5" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1.5"/>
<!-- center divider -->
<line x1="958" y1="437" x2="958" y2="481" stroke="#10B981" stroke-width="1" stroke-dasharray="3,2" opacity="0.6"/>
<!-- LEFT: lint.nu -->
<text x="918" y="455" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#10B981" text-anchor="middle" font-weight="500">lint.nu</text>
<text x="918" y="469" font-family="'Inter',sans-serif" font-size="8" fill="#64748B" text-anchor="middle">(clippy)</text>
<!-- RIGHT: fmt.nu -->
<text x="998" y="455" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#10B981" text-anchor="middle" font-weight="500">fmt.nu</text>
<text x="998" y="469" font-family="'Inter',sans-serif" font-size="8" fill="#64748B" text-anchor="middle">(cargo fmt)</text>
</g>
<!-- join arc below -->
<path d="M878,481 L878,494 L1038,494 L1038,481" fill="none" stroke="#10B981" stroke-width="1" stroke-dasharray="3,2" opacity="0.5"/>
<text x="958" y="506" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#334155" text-anchor="middle">join_all → PipelineContext</text>
<!-- ========== STEP 8: deposit capabilities (Orchestrator, x=1075, yc=300) ========== -->
<g class="p8">
<circle cx="1075" cy="268" r="9" fill="#6366F1"/>
<text x="1075" y="272" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">8</text>
<rect x="1038" y="277" width="74" height="46" rx="5" fill="#6366F1" fill-opacity="0.15" stroke="#6366F1" stroke-width="1.5"/>
<text x="1075" y="293" font-family="'IBM Plex Mono',monospace" font-size="8.5" fill="#818CF8" text-anchor="middle" font-weight="500">deposit:</text>
<text x="1075" y="307" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#64748B" text-anchor="middle">linted-code</text>
<text x="1075" y="318" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#64748B" text-anchor="middle">formatted-code</text>
</g>
<!-- ========== STEP 9: Stage 1: build.nu (Stage 1, x=1190, yc=540) ========== -->
<g class="p9">
<circle cx="1190" cy="508" r="9" fill="#818CF8"/>
<text x="1190" y="512" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">9</text>
<rect x="1153" y="517" width="74" height="44" rx="5" fill="#818CF8" fill-opacity="0.15" stroke="#818CF8" stroke-width="1.5"/>
<text x="1190" y="533" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#818CF8" text-anchor="middle" font-weight="500">build.nu</text>
<text x="1190" y="546" font-family="'Inter',sans-serif" font-size="8" fill="#64748B" text-anchor="middle">(cargo build)</text>
<!-- Vault annotation -->
<rect x="1153" y="567" width="74" height="22" rx="3" fill="#F1F5F9" stroke="#F59E0B" stroke-width="1" stroke-dasharray="3,2"/>
<text x="1190" y="578" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#F59E0B" text-anchor="middle">&#x1F510; Vault cred</text>
<text x="1190" y="588" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#F59E0B" text-anchor="middle">TTL=step timeout</text>
<text x="1190" y="603" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#334155" text-anchor="middle">deposit: built-artifact</text>
</g>
<!-- ========== STEP 10: Stage 2: install.nu (Stage 2, x=1310, yc=620) ========== -->
<g class="p10">
<circle cx="1310" cy="588" r="9" fill="#A5B4FC"/>
<text x="1310" y="592" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">10</text>
<rect x="1263" y="597" width="94" height="44" rx="5" fill="#A5B4FC" fill-opacity="0.12" stroke="#A5B4FC" stroke-width="1.5"/>
<text x="1310" y="613" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#A5B4FC" text-anchor="middle" font-weight="500">install.nu</text>
<text x="1310" y="625" font-family="'Inter',sans-serif" font-size="8" fill="#64748B" text-anchor="middle">extracts: built-artifact</text>
<text x="1310" y="650" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#334155" text-anchor="middle">deposit: installed</text>
</g>
<!-- ========== STEP 11: pipeline_run → SurrealDB (SurrealDB, x=1420, yc=700) ========== -->
<g class="p11">
<circle cx="1420" cy="668" r="9" fill="#A78BFA"/>
<text x="1420" y="672" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">11</text>
<rect x="1373" y="677" width="94" height="44" rx="5" fill="#A78BFA" fill-opacity="0.15" stroke="#A78BFA" stroke-width="1.5"/>
<text x="1420" y="693" font-family="'IBM Plex Mono',monospace" font-size="8.5" fill="#A78BFA" text-anchor="middle" font-weight="500">pipeline_run</text>
<text x="1420" y="706" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#64748B" text-anchor="middle">status: success</text>
<text x="1420" y="727" font-family="'Inter',sans-serif" font-size="7.5" fill="#334155" text-anchor="middle">all steps persisted</text>
</g>
<!-- ========== STEP 12: emit result event (NATS diamond, x=1530, yc=220) ========== -->
<g class="nd p12">
<circle cx="1530" cy="178" r="9" fill="#22D3EE"/>
<text x="1530" y="182" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">12</text>
<polygon points="1530,185 1592,220 1530,255 1468,220" fill="#22D3EE" fill-opacity="0.12" stroke="#22D3EE" stroke-width="1.5"/>
<text x="1530" y="208" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">dev.crate.</text>
<text x="1530" y="221" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">provisioning-cli</text>
<text x="1530" y="234" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">.built</text>
<text x="1530" y="264" font-family="'Inter',sans-serif" font-size="7.5" fill="#334155" text-anchor="middle">duration_ms, status</text>
</g>
<!-- ========== FLOW ARROWS ========== -->
<!-- 1→2: git push triggers webhook (Developer lane →) -->
<line x1="255" y1="140" x2="292" y2="162" stroke="#F97316" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#ao)" class="fd"/>
<!-- 2→3: NATS event received (cross-lane: NATS down to Orchestrator) -->
<line x1="330" y1="216" x2="330" y2="270" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ai)"/>
<line x1="330" y1="299" x2="415" y2="299" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#ai)" class="fds"/>
<!-- 3→4: Orchestrator requests NKey verify (cross-lane down to Auth) -->
<line x1="487" y1="299" x2="525" y2="365" stroke="#F59E0B" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ag)"/>
<!-- 4→5: Auth ok, Orchestrator proceeds (cross-lane back up + forward) -->
<line x1="606" y1="385" x2="644" y2="299" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ai)"/>
<!-- 5→6: ActionGraph built, Cedar authorize -->
<line x1="732" y1="300" x2="760" y2="365" stroke="#F59E0B" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ag)"/>
<!-- 6→7: Auth permits, launch Stage 0 -->
<line x1="833" y1="398" x2="878" y2="445" stroke="#10B981" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#agr)"/>
<!-- 7→8: Stage 0 join → deposit to Orchestrator -->
<line x1="1038" y1="458" x2="1075" y2="323" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ai)"/>
<!-- 8→9: Deposit done → Stage 1 -->
<line x1="1075" y1="323" x2="1153" y2="517" stroke="#818CF8" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#aib)"/>
<!-- 9→10: Stage 1 done → Stage 2 -->
<line x1="1227" y1="540" x2="1263" y2="617" stroke="#A5B4FC" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ail)"/>
<!-- 10→11: Stage 2 done → SurrealDB ACK -->
<line x1="1310" y1="641" x2="1373" y2="693" stroke="#A78BFA" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ap)"/>
<!-- 11→12: SurrealDB persisted → emit result NATS -->
<path d="M1420,677 L1420,220 L1474,220" fill="none" stroke="#22D3EE" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#ac)" class="fds"/>
<!-- Orchestrator ↔ SurrealDB (bidirectional state) -->
<path d="M1075,323 Q1075,700 1373,700" fill="none" stroke="#A78BFA" stroke-width="1" stroke-dasharray="3,3" opacity="0.4"/>
<!-- ========== ROLLBACK PATH ========== -->
<path d="M1153,574 L800,574" fill="none" stroke="#F43F5E" stroke-width="2" stroke-dasharray="7,4" marker-end="url(#ar)" opacity="0.85"/>
<rect x="850" y="553" width="210" height="17" rx="3" fill="#F1F5F9" fill-opacity="0.95"/>
<text x="955" y="565" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#F43F5E" text-anchor="middle">rollback path: compensate.nu in reverse order</text>
<!-- ========== LOGO above LEGEND ========== -->
<g transform="translate(10,15) scale(0.2)">
<g transform="translate(20,50)">
<rect x="-220" y="18" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.5;0.8;0.5" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<rect x="-220" y="88" width="220" height="24" rx="5" fill="url(#hpLayerGrad)">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.15s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
<rect x="-220" y="158" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.3s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.8;0.5;0.8" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<path d="M35 42 Q35 65 70 85 Q95 100 95 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.5s"/></path>
<path d="M185 42 Q185 65 150 85 Q125 100 125 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.6s"/></path>
<path d="M95 100 Q95 100 70 115 Q35 135 35 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.7s"/></path>
<path d="M125 100 Q125 100 150 115 Q185 135 185 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.8s"/></path>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.5s"><mpath href="#hpPathIn1"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.8s"><mpath href="#hpPathIn2"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.1s"><mpath href="#hpPathOut1"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.1s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.4s"><mpath href="#hpPathOut2"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.4s"/></circle>
<circle cx="35" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.25s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="185" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.3s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="35" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.4s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle cx="185" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.45s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<rect x="85" y="75" width="50" height="50" rx="9" fill="url(#hpProcessorGrad)" filter="url(#hpProcessorGlow)" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.3s" fill="freeze" begin="0.45s"/></rect>
<rect x="97" y="87" width="26" height="26" rx="5" fill="#ffffff" opacity="0"><animate attributeName="opacity" values="0;0.95" dur="0.25s" fill="freeze" begin="0.55s"/></rect>
<rect x="101" y="96" width="4" height="8" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.7s"/><animate attributeName="height" values="8;14;6;10;8" dur="0.6s" repeatCount="indefinite" begin="1.2s"/><animate attributeName="y" values="96;93;97;95;96" dur="0.6s" repeatCount="indefinite" begin="1.2s"/></rect>
<rect x="108" y="93" width="4" height="14" rx="1" fill="#06B6D4" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.75s"/><animate attributeName="height" values="14;8;12;16;14" dur="0.55s" repeatCount="indefinite" begin="1.25s"/><animate attributeName="y" values="93;96;94;92;93" dur="0.55s" repeatCount="indefinite" begin="1.25s"/></rect>
<rect x="115" y="95" width="4" height="10" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.8s"/><animate attributeName="height" values="10;16;8;12;10" dur="0.5s" repeatCount="indefinite" begin="1.3s"/><animate attributeName="y" values="95;92;96;94;95" dur="0.5s" repeatCount="indefinite" begin="1.3s"/></rect>
<rect x="85" y="75" width="50" height="50" rx="9" fill="none" stroke="#22D3EE" stroke-width="1.5" opacity="0"><animate attributeName="x" values="85;70" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="y" values="75;60" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="width" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="height" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="rx" values="9;14" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="opacity" values="0.5;0" dur="2s" repeatCount="indefinite" begin="1.5s"/></rect>
</g>
<g clip-path="url(#hpTextReveal)">
<text x="280" y="175" font-family="'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-weight="700" font-size="72" fill="url(#hpShimmer)">Stratum<tspan fill="url(#hpProcessorGrad)">I</tspan>Ops</text>
</g>
<rect x="280" y="115" width="4" height="70" rx="2" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1;1;0;0" dur="0.8s" fill="freeze" begin="1s" keyTimes="0;0.1;0.5;0.51;1"/><animate attributeName="x" values="280;980" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></rect>
<line x1="280" y1="195" x2="280" y2="195" stroke="url(#hpUnderlineGrad)" stroke-width="3" stroke-linecap="round" opacity="0"><animate attributeName="opacity" values="0;0.7" dur="0.1s" fill="freeze" begin="1.4s"/><animate attributeName="x2" values="280;750" dur="0.8s" fill="freeze" begin="1.4s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></line>
</g>
<!-- ========== LEGEND (y=760-800) ========== -->
<text x="192" y="770" font-family="'Inter',sans-serif" font-size="9" fill="#334155" font-weight="600">Legend:</text>
<rect x="240" y="759" width="11" height="11" rx="2" fill="#22D3EE" fill-opacity="0.25" stroke="#22D3EE" stroke-width="1"/>
<text x="256" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#64748B">NATS events</text>
<rect x="335" y="759" width="11" height="11" rx="2" fill="#6366F1" fill-opacity="0.25" stroke="#6366F1" stroke-width="1"/>
<text x="351" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#64748B">Orchestrator</text>
<rect x="434" y="759" width="11" height="11" rx="2" fill="#F59E0B" fill-opacity="0.25" stroke="#F59E0B" stroke-width="1"/>
<text x="450" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#64748B">Auth (NKeys + Cedar)</text>
<rect x="578" y="759" width="11" height="11" rx="2" fill="#10B981" fill-opacity="0.25" stroke="#10B981" stroke-width="1"/>
<text x="594" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#64748B">Execution (Stage 0)</text>
<rect x="726" y="759" width="11" height="11" rx="2" fill="#A78BFA" fill-opacity="0.25" stroke="#A78BFA" stroke-width="1"/>
<text x="742" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#64748B">State (SurrealDB)</text>
<line x1="852" y1="765" x2="875" y2="765" stroke="#F43F5E" stroke-width="2" stroke-dasharray="5,3"/>
<text x="882" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#64748B">Rollback path</text>
<text x="1588" y="775" font-family="'Inter',sans-serif" font-size="8" fill="#374151" text-anchor="end">StratumIOps · stratum-orchestrator</text>
<text x="1588" y="789" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#374151" text-anchor="end">stratum-orchestrator · build pipeline</text>
</svg>

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -0,0 +1,477 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1170 830">
<defs>
<style>
.flow { animation: flow-a 3s linear infinite; }
.flow-fast { animation: flow-a 1.8s linear infinite; }
.flow-slow { animation: flow-a 5s linear infinite; }
.flow-vslow { animation: flow-a 7s linear infinite; }
@keyframes flow-a { from { stroke-dashoffset: 24; } to { stroke-dashoffset: 0; } }
.orbit { animation: orbit-a 8s linear infinite; }
@keyframes orbit-a { from { stroke-dashoffset: 0; } to { stroke-dashoffset: -68; } }
.glow-pulse { animation: glow-a 3s ease-in-out infinite; }
@keyframes glow-a { 0%,100% { opacity:.08; } 50% { opacity:.28; } }
.hb { animation: hb-a 3s ease-in-out infinite; }
.hb-d1 { animation-delay: .4s; }
.hb-d2 { animation-delay: .8s; }
.hb-d3 { animation-delay:1.2s; }
.hb-d4 { animation-delay:1.6s; }
.hb-d5 { animation-delay:2.0s; }
@keyframes hb-a { 0%,100% { opacity:.85; } 50% { opacity:1; } }
.particle-spin1 { animation: spin1 6s linear infinite; }
.particle-spin2 { animation: spin2 9s linear infinite; }
.particle-spin3 { animation: spin3 14s linear infinite; }
@keyframes spin1 { from { transform: rotate(0deg) translateX(120px); } to { transform: rotate(360deg) translateX(120px); } }
@keyframes spin2 { from { transform: rotate(120deg) translateX(120px); } to { transform: rotate(480deg) translateX(120px); } }
@keyframes spin3 { from { transform: rotate(240deg) translateX(120px); } to { transform: rotate(600deg) translateX(120px); } }
</style>
<radialGradient id="bg-grad" cx="50%" cy="42%" r="58%">
<stop offset="0%" stop-color="#131929"/>
<stop offset="100%" stop-color="#0F172A"/>
</radialGradient>
<linearGradient id="title-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="50%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#1e1b4b"/>
</linearGradient>
<linearGradient id="orch-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="100%" stop-color="#0d0b22"/>
</linearGradient>
<linearGradient id="auth-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1c1700"/>
<stop offset="100%" stop-color="#0f0e00"/>
</linearGradient>
<linearGradient id="surreal-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1a0f2e"/>
<stop offset="100%" stop-color="#100820"/>
</linearGradient>
<linearGradient id="vault-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1c1400"/>
<stop offset="100%" stop-color="#110d00"/>
</linearGradient>
<linearGradient id="oci-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#021f20"/>
<stop offset="100%" stop-color="#011314"/>
</linearGradient>
<linearGradient id="forgejo-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e0f02"/>
<stop offset="100%" stop-color="#120900"/>
</linearGradient>
<linearGradient id="exec-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#021a0b"/>
<stop offset="100%" stop-color="#011007"/>
</linearGradient>
<linearGradient id="agent-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#12082a"/>
<stop offset="100%" stop-color="#0a0518"/>
</linearGradient>
<linearGradient id="ncl-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#6366F1" stop-opacity=".25"/>
<stop offset="50%" stop-color="#22D3EE" stop-opacity=".15"/>
<stop offset="100%" stop-color="#6366F1" stop-opacity=".25"/>
</linearGradient>
<linearGradient id="event-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e0a00"/>
<stop offset="100%" stop-color="#110600"/>
</linearGradient>
<linearGradient id="mod-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1a1850"/>
<stop offset="100%" stop-color="#10102e"/>
</linearGradient>
<linearGradient id="mod-ag-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1E1060"/>
<stop offset="100%" stop-color="#140C48"/>
</linearGradient>
<linearGradient id="mod-pc-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#082030"/>
<stop offset="100%" stop-color="#041520"/>
</linearGradient>
<linearGradient id="mod-sr-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0A2010"/>
<stop offset="100%" stop-color="#061508"/>
</linearGradient>
<linearGradient id="mod-re-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#201800"/>
<stop offset="100%" stop-color="#140F00"/>
</linearGradient>
<linearGradient id="orch-stripe" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#6366F1"/>
<stop offset="100%" stop-color="#22D3EE"/>
</linearGradient>
<filter id="shadow" x="-15%" y="-15%" width="130%" height="130%">
<feDropShadow dx="0" dy="3" stdDeviation="8" flood-color="#000" flood-opacity=".5"/>
</filter>
<filter id="glow-c" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<marker id="arr-cyan" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#22D3EE"/></marker>
<marker id="arr-gold" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#F59E0B"/></marker>
<marker id="arr-green" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#10B981"/></marker>
<marker id="arr-orange" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#F97316"/></marker>
<marker id="arr-purple" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#A78BFA"/></marker>
<marker id="arr-indigo" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#818CF8"/></marker>
<marker id="arr-silver" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#64748B"/></marker>
<marker id="arr-dcyan" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#06B6D4"/></marker>
<!-- ─── stratumiops-h logo defs ─── -->
<linearGradient id="hpLayerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#4F46E5"/>
</linearGradient>
<linearGradient id="hpProcessorGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#06B6D4"/>
</linearGradient>
<linearGradient id="hpFlowGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="0%;100%;0%" dur="2.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="100%;200%;100%" dur="2.5s" repeatCount="indefinite"/>
</linearGradient>
<linearGradient id="hpUnderlineGrad" x1="280" y1="195" x2="750" y2="195" gradientUnits="userSpaceOnUse">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
</linearGradient>
<linearGradient id="hpShimmer" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="40%" style="stop-color:#6366F1"/>
<stop offset="50%" style="stop-color:#22D3EE"/><stop offset="60%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="-100%;100%" dur="3s" repeatCount="indefinite" begin="2s"/>
<animate attributeName="x2" values="0%;200%" dur="3s" repeatCount="indefinite" begin="2s"/>
</linearGradient>
<filter id="hpProcessorGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="5" result="coloredBlur"><animate attributeName="stdDeviation" values="4;8;4" dur="1.5s" repeatCount="indefinite"/></feGaussianBlur>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="hpNodeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<clipPath id="hpTextReveal">
<rect x="280" y="80" width="0" height="150">
<animate attributeName="width" values="0;800" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
</clipPath>
<path id="hpPathIn1" d="M25 40 Q25 65 55 80 Q85 95 95 100" fill="none"/>
<path id="hpPathIn2" d="M215 40 Q215 65 185 80 Q155 95 145 100" fill="none"/>
<path id="hpPathOut1" d="M95 100 Q85 105 55 120 Q25 135 25 160" fill="none"/>
<path id="hpPathOut2" d="M145 100 Q155 105 185 120 Q215 135 215 160" fill="none"/>
</defs>
<!-- ─── BACKGROUND ─── -->
<rect width="1170" height="830" fill="url(#bg-grad)"/>
<g opacity=".025">
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M40 0L0 0 0 40" fill="none" stroke="#fff" stroke-width=".5"/>
</pattern>
<rect width="1170" height="830" fill="url(#grid)"/>
</g>
<g opacity=".3">
<circle cx="90" cy="200" r=".8" fill="#fff"/>
<circle cx="420" cy="88" r=".6" fill="#fff"/>
<circle cx="980" cy="130" r="1" fill="#fff"/>
<circle cx="55" cy="600" r=".5" fill="#fff"/>
<circle cx="700" cy="800" r=".6" fill="#fff"/>
</g>
<!-- ─── TITLE BAR ─── -->
<rect x="0" y="0" width="1170" height="60" fill="url(#title-grad)"/>
<line x1="0" y1="60" x2="1170" y2="60" stroke="#6366F1" stroke-width=".8" opacity=".5"/>
<text x="600" y="30" text-anchor="middle" fill="#22D3EE" font-family="'IBM Plex Mono',monospace" font-size="19" font-weight="700" letter-spacing="3" dominant-baseline="middle">STRATUM ORCHESTRATOR</text>
<text x="600" y="50" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="9" letter-spacing="1.5">Event-Driven · Graph-Guided · Atomic Execution · Stateless · OCI-Native</text>
<!-- ─── EVENT SOURCES ROW ─── -->
<text x="400" y="82" text-anchor="middle" fill="#F97316" font-family="'IBM Plex Mono',monospace" font-size="8" letter-spacing="2" opacity=".8">EVENT SOURCES</text>
<rect x="55" y="90" width="128" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb"/>
<rect x="55" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="55" y="90" width="128" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".4"/>
<text x="70" y="111" fill="#fff" font-family="Inter,sans-serif" font-size="11" font-weight="600">provisioning</text>
<text x="70" y="127" fill="#F97316" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".8">emits dev.crate.&gt;</text>
<text x="70" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">crate-modified · deploy</text>
<rect x="200" y="90" width="118" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d1"/>
<rect x="200" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="200" y="90" width="118" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".4"/>
<text x="215" y="111" fill="#fff" font-family="Inter,sans-serif" font-size="11" font-weight="600">kogral</text>
<text x="215" y="127" fill="#F97316" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".8">emits dev.knowledge.&gt;</text>
<text x="215" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">node-updated · indexed</text>
<rect x="335" y="90" width="120" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d2"/>
<rect x="335" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="335" y="90" width="120" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".4"/>
<text x="350" y="111" fill="#fff" font-family="Inter,sans-serif" font-size="11" font-weight="600">syntaxis</text>
<text x="350" y="127" fill="#F97316" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".8">emits dev.project.&gt;</text>
<text x="350" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">phase · task-completed</text>
<rect x="472" y="90" width="135" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d3"/>
<rect x="472" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="472" y="90" width="135" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".4"/>
<text x="487" y="111" fill="#fff" font-family="Inter,sans-serif" font-size="11" font-weight="600">stratumiops</text>
<text x="487" y="127" fill="#F97316" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".8">emits dev.model.&gt;</text>
<text x="487" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">llm-call · embed-request</text>
<rect x="624" y="90" width="118" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d4"/>
<rect x="624" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="624" y="90" width="118" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".4"/>
<text x="639" y="111" fill="#fff" font-family="Inter,sans-serif" font-size="11" font-weight="600">typedialog</text>
<text x="639" y="127" fill="#F97316" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".8">emits dev.form.&gt;</text>
<text x="639" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">submitted · validated</text>
<rect x="755" y="90" width="120" height="60" rx="6" fill="none" stroke="#64748B" stroke-width=".8" stroke-dasharray="4 3" opacity=".5"/>
<text x="815" y="127" text-anchor="middle" fill="#64748B" font-family="Inter,sans-serif" font-size="19">+ more ...</text>
<!-- ─── CONNECTIONS: PROJECTS → NATS (NATS cx=355 cy=385 r=129 → anillo exterior que pulsa) ─── -->
<line x1="119" y1="150" x2="317" y2="262" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="259" y1="150" x2="337" y2="257" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="395" y1="150" x2="355" y2="256" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="539" y1="150" x2="374" y2="258" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="686" y1="150" x2="396" y2="263" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<!-- AUTH LAYER removed: NKeys → badge on NATS→Orch arrow; Cedar → inside RuleEngine -->
<!-- ─── NATS ORBITAL RING (cx=355, cy=385, r×0.9: 129/123/120/66) ─── -->
<circle cx="355" cy="385" r="129" fill="none" stroke="#22D3EE" stroke-width="14" opacity=".04" class="glow-pulse"/>
<circle cx="355" cy="385" r="123" fill="none" stroke="#22D3EE" stroke-width="10" opacity=".06" class="glow-pulse" filter="url(#glow-c)"/>
<circle cx="355" cy="385" r="120" fill="none" stroke="#64748B" stroke-width="1.5" stroke-dasharray="10 7" class="orbit"/>
<circle cx="355" cy="385" r="66" fill="none" stroke="#6366F1" stroke-width=".5" opacity=".2"/>
<radialGradient id="nats-inner" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#22D3EE" stop-opacity=".12"/>
<stop offset="100%" stop-color="#6366F1" stop-opacity=".04"/>
</radialGradient>
<circle cx="355" cy="385" r="66" fill="url(#nats-inner)"/>
<text x="355" y="379" text-anchor="middle" fill="#22D3EE" font-family="'IBM Plex Mono',monospace" font-size="20" font-weight="700" filter="url(#glow-c)">NATS</text>
<text x="355" y="398" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="9">JetStream</text>
<text x="344" y="235" text-anchor="middle" fill="#22D3EE" font-family="'IBM Plex Mono',monospace" font-size="9" opacity=".7">dev.&gt;</text>
<g transform="translate(355,385)">
<circle r="4" fill="#22D3EE" opacity=".9" class="particle-spin1"/>
<circle r="3.5" fill="#6366F1" opacity=".7" class="particle-spin2"/>
<circle r="3" fill="#F97316" opacity=".5" class="particle-spin3"/>
</g>
<!-- ─── NATS → ORCHESTRATOR (orbit right x=475, orch left x=498) — NKey checkpoint badge ─── -->
<path d="M 475,385 L 495,385" stroke="#22D3EE" stroke-width="3" stroke-dasharray="8 4" class="flow-fast" marker-end="url(#arr-cyan)" filter="url(#glow-c)"/>
<circle r="4" fill="#22D3EE" opacity=".85">
<animateMotion dur="0.25s" repeatCount="indefinite" path="M 475,385 L 495,385"/>
</circle>
<circle r="3" fill="#6366F1" opacity=".7">
<animateMotion dur="0.25s" repeatCount="indefinite" begin="0.08s" path="M 475,385 L 495,385"/>
</circle>
<!-- NKey verify badge — checkpoint at connection boundary -->
<rect x="379" y="419" width="78" height="22" rx="3" fill="#1c1700" stroke="#F59E0B" stroke-width=".7" opacity=".9"/>
<text x="418" y="429" text-anchor="middle" fill="#F59E0B" font-family="'IBM Plex Mono',monospace" font-size="7">NKey verify</text>
<text x="418" y="439" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="6.5">ed25519 JWT</text>
<!-- ─── ORCHESTRATOR BOX (x=498, y=250, w=375, h=300, center x=685) ─── -->
<rect x="498" y="250" width="375" height="300" rx="10" fill="#000" opacity=".2" transform="translate(3,5)"/>
<rect x="498" y="250" width="375" height="300" rx="10" fill="url(#orch-grad)" filter="url(#shadow)" class="hb"/>
<rect x="498" y="250" width="375" height="300" rx="10" fill="none" stroke="#6366F1" stroke-width=".8" opacity=".5" class="glow-pulse"/>
<rect x="498" y="250" width="4" height="300" rx="2" fill="url(#orch-stripe)"/>
<text x="685" y="273" text-anchor="middle" fill="#fff" font-family="Inter,sans-serif" font-size="14" font-weight="700">STRATUM ORCHESTRATOR</text>
<text x="685" y="290" text-anchor="middle" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Agnostic · Stateless · Graph-Guided</text>
<!-- Internal modules 2×2 (cols of 170px each, gap 8px) -->
<!-- ActionGraph -->
<rect x="510" y="305" width="170" height="72" rx="6" fill="url(#mod-ag-grad)" stroke="#4F46E5" stroke-width=".7" opacity=".9"/>
<text x="522" y="325" fill="#A5B4FC" font-family="Inter,sans-serif" font-size="11" font-weight="600">◈ ActionGraph</text>
<text x="522" y="341" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">in-memory · Nickel nodes</text>
<text x="522" y="355" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">topo-sort · cycle-detect</text>
<!-- PipelineContext -->
<rect x="690" y="305" width="171" height="72" rx="6" fill="url(#mod-pc-grad)" stroke="#06B6D4" stroke-width=".7" opacity=".9"/>
<text x="702" y="325" fill="#A5B4FC" font-family="Inter,sans-serif" font-size="11" font-weight="600">⬡ PipelineCtx</text>
<text x="702" y="341" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">DB-first · typed caps</text>
<text x="702" y="355" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">schema-validated</text>
<!-- StageRunner -->
<rect x="510" y="389" width="170" height="130" rx="6" fill="url(#mod-sr-grad)" stroke="#10B981" stroke-width=".7" opacity=".9"/>
<text x="522" y="407" fill="#A5B4FC" font-family="Inter,sans-serif" font-size="11" font-weight="600">▶ StageRunner</text>
<text x="522" y="423" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">JoinSet · parallel stages</text>
<text x="522" y="437" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">CancellationToken</text>
<text x="522" y="451" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">retry + backoff on failure</text>
<text x="522" y="465" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">saga compensate.nu</text>
<!-- RuleEngine (Cedar + NKey as named sub-sections) -->
<rect x="690" y="389" width="171" height="130" rx="6" fill="url(#mod-re-grad)" stroke="#F59E0B" stroke-width=".7" opacity=".9"/>
<text x="702" y="407" fill="#A5B4FC" font-family="Inter,sans-serif" font-size="11" font-weight="600">⚙ RuleEngine</text>
<rect x="810" y="393" width="44" height="13" rx="3" fill="#1c1700" stroke="#F59E0B" stroke-width=".6"/>
<text x="832" y="402" text-anchor="middle" fill="#F59E0B" font-family="'IBM Plex Mono',monospace" font-size="7">Cedar</text>
<line x1="702" y1="415" x2="854" y2="415" stroke="#4F46E5" stroke-width=".4" opacity=".5"/>
<text x="702" y="428" fill="#F59E0B" font-family="'IBM Plex Mono',monospace" font-size="8" font-weight="600">◈ Cedar</text>
<text x="702" y="441" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">permit · forbid · conditions</text>
<text x="702" y="454" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">per-node authz policies</text>
<line x1="702" y1="463" x2="854" y2="463" stroke="#4F46E5" stroke-width=".4" opacity=".5"/>
<text x="702" y="476" fill="#F59E0B" font-family="'IBM Plex Mono',monospace" font-size="8" font-weight="600">◈ NKey</text>
<text x="702" y="489" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">ed25519 asymmetric keys</text>
<text x="702" y="502" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">JWT per-process · verify</text>
<text x="685" y="538" text-anchor="middle" fill="#F87171" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".75">↺ Saga rollback on failure · compensate.nu in reverse</text>
<!-- ─── RIGHT PANEL: DATA STORES (x=926, w=232, ends x=1158) ─── -->
<!-- Orch right x=873 → stores: flechas a sus centros verticales (cajas 25px arriba) -->
<path d="M 873,256 L 926,216" stroke="#A78BFA" stroke-width="1.5" stroke-dasharray="4 3" class="flow" marker-end="url(#arr-purple)" fill="none" opacity=".8"/>
<path d="M 873,350 L 926,310" stroke="#F59E0B" stroke-width="1.5" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-gold)" fill="none" opacity=".8"/>
<path d="M 873,444 L 926,404" stroke="#06B6D4" stroke-width="1" stroke-dasharray="3 4" class="flow-slow" marker-end="url(#arr-dcyan)" fill="none" opacity=".7"/>
<!-- SurrealDB -->
<rect x="926" y="175" width="197" height="82" rx="8" fill="url(#surreal-grad)" filter="url(#shadow)" class="hb hb-d2"/>
<rect x="926" y="175" width="3" height="82" rx="1" fill="#A78BFA"/>
<rect x="926" y="175" width="197" height="82" rx="8" fill="none" stroke="#A78BFA" stroke-width=".8" opacity=".5"/>
<text x="941" y="198" fill="#A78BFA" font-family="Inter,sans-serif" font-size="12" font-weight="700">SurrealDB</text>
<text x="941" y="215" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">Pipeline state · Step results</text>
<text x="941" y="231" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">orchestrator_state ns</text>
<text x="941" y="247" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">crash recovery</text>
<!-- SecretumVault -->
<rect x="926" y="269" width="197" height="82" rx="8" fill="url(#vault-grad)" filter="url(#shadow)" class="hb hb-d3"/>
<rect x="926" y="269" width="3" height="82" rx="1" fill="#F59E0B"/>
<rect x="926" y="269" width="197" height="82" rx="8" fill="none" stroke="#F59E0B" stroke-width=".8" opacity=".5"/>
<text x="941" y="292" fill="#F59E0B" font-family="Inter,sans-serif" font-size="12" font-weight="700">SecretumVault</text>
<text x="941" y="309" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">Credentials · TTL leases</text>
<text x="941" y="325" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">vault:/secret/...</text>
<text x="941" y="341" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">never in NATS payload</text>
<!-- Zot OCI -->
<rect x="926" y="363" width="197" height="82" rx="8" fill="url(#oci-grad)" filter="url(#shadow)" class="hb hb-d4"/>
<rect x="926" y="363" width="3" height="82" rx="1" fill="#06B6D4"/>
<rect x="926" y="363" width="197" height="82" rx="8" fill="none" stroke="#06B6D4" stroke-width=".8" opacity=".5"/>
<text x="941" y="386" fill="#06B6D4" font-family="Inter,sans-serif" font-size="12" font-weight="700">Zot OCI Registry</text>
<text x="941" y="403" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">Node defs · Nickel libs</text>
<text x="941" y="419" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">oci://registry/nodes/</text>
<text x="941" y="435" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">content-addressed · signed</text>
<!-- ─── RIGHT PANEL: OPTIONAL SERVICES ─── -->
<text x="1042" y="533" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8" letter-spacing="2">OPTIONAL</text>
<!-- Flechas opcionales: escalonadas, cajas 40px más abajo -->
<path d="M 873,495 C 903,495 903,559 926,559" stroke="#64748B" stroke-width="1" stroke-dasharray="3 5" class="flow-vslow" marker-end="url(#arr-silver)" fill="none" opacity=".5"/>
<path d="M 873,510 C 905,510 905,653 926,653" stroke="#64748B" stroke-width="1" stroke-dasharray="3 5" class="flow-vslow" marker-end="url(#arr-silver)" fill="none" opacity=".5"/>
<path d="M 873,525 C 907,525 907,744 926,744" stroke="#64748B" stroke-width="1" stroke-dasharray="3 5" class="flow-vslow" marker-end="url(#arr-silver)" fill="none" opacity=".4"/>
<!-- Kogral -->
<rect x="926" y="518" width="197" height="82" rx="8" fill="#0F172A" stroke="#64748B" stroke-width=".8" stroke-dasharray="5 4" opacity=".7" class="hb hb-d2"/>
<text x="941" y="541" fill="#94A3B8" font-family="Inter,sans-serif" font-size="12" font-weight="600">Kogral</text>
<text x="941" y="558" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Knowledge graph</text>
<text x="941" y="574" fill="#4B5563" font-family="'IBM Plex Mono',monospace" font-size="8">node-updated triggers</text>
<rect x="1071" y="522" width="46" height="16" rx="3" fill="#1E293B"/>
<text x="1094" y="533" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">optional</text>
<!-- Syntaxis -->
<rect x="926" y="612" width="197" height="82" rx="8" fill="#0F172A" stroke="#64748B" stroke-width=".8" stroke-dasharray="5 4" opacity=".7" class="hb hb-d3"/>
<text x="941" y="635" fill="#94A3B8" font-family="Inter,sans-serif" font-size="12" font-weight="600">Syntaxis</text>
<text x="941" y="652" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Project orchestration</text>
<text x="941" y="668" fill="#4B5563" font-family="'IBM Plex Mono',monospace" font-size="8">phase-transition events</text>
<rect x="1071" y="616" width="46" height="16" rx="3" fill="#1E293B"/>
<text x="1094" y="627" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">optional</text>
<!-- TypeDialog -->
<rect x="926" y="706" width="197" height="75" rx="8" fill="#0F172A" stroke="#64748B" stroke-width=".8" stroke-dasharray="5 4" opacity=".6" class="hb hb-d4"/>
<text x="941" y="729" fill="#94A3B8" font-family="Inter,sans-serif" font-size="12" font-weight="600">TypeDialog</text>
<text x="941" y="746" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Service config UI</text>
<text x="941" y="764" fill="#4B5563" font-family="'IBM Plex Mono',monospace" font-size="8">startup config NCL only</text>
<!-- ─── LEFT: Git repo (x=10, y=295 — arriba izq junto a NATS) ─── -->
<!-- Orch → Git repo: bezier más curvada para alejarse de NATS -->
<path d="M 498,545 C 120,650 50,350 180,337" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" fill="none" opacity=".5"/>
<!-- Git repo → NATS: línea recta acortada 5px -->
<path d="M 180,337 L 230,353" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" fill="none" opacity=".6"/>
<rect x="30" y="295" width="150" height="85" rx="8" fill="url(#forgejo-grad)" filter="url(#shadow)" class="hb hb-d5"/>
<rect x="30" y="295" width="3" height="85" rx="1" fill="#F97316"/>
<rect x="30" y="295" width="150" height="85" rx="8" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="45" y="318" fill="#F97316" font-family="Inter,sans-serif" font-size="12" font-weight="700">Git repo</text>
<text x="45" y="335" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">Git events → NATS</text>
<text x="45" y="351" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">webhook → dev.crate.&gt;</text>
<text x="45" y="367" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">push · tag · pr</text>
<!-- ─── EXECUTION LAYER mismo nivel (Nu x=380 y=623, AI x=615 y=623, sep=35px) ─── -->
<!-- Orch → Nu Executor: sale borde inferior x=510 (20px dcha), U-curve por debajo, llega al fondo (480,733) apuntando arriba, acortada 30px -->
<path d="M 518,550 C 500,560 485,610 483,614" stroke="#10B981" stroke-width="1.5" stroke-dasharray="5 3" class="flow" marker-end="url(#arr-green)" fill="none" opacity=".8"/>
<!-- Orch → AI Agent: copia de Nu pero adaptada a AI, color indigo -->
<path d="M 773,550 C 755,560 717,610 714,616" stroke="#818CF8" stroke-width="1.5" stroke-dasharray="5 3" class="flow" marker-end="url(#arr-indigo)" fill="none" opacity=".8"/>
<rect x="380" y="623" width="200" height="80" rx="8" fill="url(#exec-grad)" filter="url(#shadow)" class="hb"/>
<rect x="380" y="623" width="3" height="80" rx="1" fill="#10B981"/>
<rect x="380" y="623" width="200" height="80" rx="8" fill="none" stroke="#10B981" stroke-width=".8" opacity=".5"/>
<text x="395" y="644" fill="#10B981" font-family="Inter,sans-serif" font-size="12" font-weight="700">Nu Executor</text>
<text x="395" y="660" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">Atomic steps · Pure functions</text>
<text x="395" y="675" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">scripts/nu/*.nu</text>
<text x="395" y="689" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">stdout=output · exit-code=status</text>
<rect x="615" y="623" width="200" height="80" rx="8" fill="url(#agent-grad)" filter="url(#shadow)" class="hb hb-d1"/>
<rect x="615" y="623" width="3" height="80" rx="1" fill="#818CF8"/>
<rect x="615" y="623" width="200" height="80" rx="8" fill="none" stroke="#818CF8" stroke-width=".8" opacity=".5"/>
<text x="630" y="644" fill="#818CF8" font-family="Inter,sans-serif" font-size="12" font-weight="700">AI Agent</text>
<text x="630" y="660" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">stratum-llm · NATS protocol</text>
<text x="630" y="675" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">dev.agent.*.requested/responded</text>
<text x="630" y="689" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">oneshot correlation · timeout</text>
<!-- ─── NICKEL BASE LIBRARY (x=390, y=738) ─── -->
<rect x="390" y="738" width="415" height="65" rx="8" fill="#0F172A" stroke="none"/>
<rect x="390" y="738" width="415" height="65" rx="8" fill="url(#ncl-grad)"/>
<rect x="390" y="738" width="415" height="65" rx="8" fill="none" stroke="#6366F1" stroke-width=".8" stroke-dasharray="6 4" opacity=".6"/>
<text x="597" y="761" text-anchor="middle" fill="#A5B4FC" font-family="Inter,sans-serif" font-size="12" font-weight="600">Nickel Base Library</text>
<text x="597" y="777" text-anchor="middle" fill="#64748B" font-family="Inter,sans-serif" font-size="9">OCI-published · content-addressed · build-verified · typecheck-gated</text>
<text x="597" y="791" text-anchor="middle" fill="#4B5563" font-family="'IBM Plex Mono',monospace" font-size="8">orchestrator-types.ncl · capability-schemas.ncl · defaults.ncl</text>
<!-- Zot OCI → Nickel: arco hacia adentro, sale desde Zot (cajas 40px abajo) -->
<path d="M 926,414 C 900,390 900,750 805,770" stroke="#06B6D4" stroke-width="1" stroke-dasharray="3 3" class="flow-slow" marker-end="url(#arr-dcyan)" fill="none" opacity=".6"/>
<!-- ─── LOGO above LEGEND ─── -->
<g transform="translate(8,5) scale(0.2)">
<g transform="translate(20,50)">
<rect x="-220" y="18" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.5;0.8;0.5" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<rect x="-220" y="88" width="220" height="24" rx="5" fill="url(#hpLayerGrad)">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.15s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
<rect x="-220" y="158" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.3s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.8;0.5;0.8" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<path d="M35 42 Q35 65 70 85 Q95 100 95 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.5s"/></path>
<path d="M185 42 Q185 65 150 85 Q125 100 125 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.6s"/></path>
<path d="M95 100 Q95 100 70 115 Q35 135 35 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.7s"/></path>
<path d="M125 100 Q125 100 150 115 Q185 135 185 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.8s"/></path>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.5s"><mpath href="#hpPathIn1"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.8s"><mpath href="#hpPathIn2"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.1s"><mpath href="#hpPathOut1"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.1s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.4s"><mpath href="#hpPathOut2"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.4s"/></circle>
<circle cx="35" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.25s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="185" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.3s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="35" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.4s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle cx="185" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.45s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<rect x="85" y="75" width="50" height="50" rx="9" fill="url(#hpProcessorGrad)" filter="url(#hpProcessorGlow)" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.3s" fill="freeze" begin="0.45s"/></rect>
<rect x="97" y="87" width="26" height="26" rx="5" fill="#ffffff" opacity="0"><animate attributeName="opacity" values="0;0.95" dur="0.25s" fill="freeze" begin="0.55s"/></rect>
<rect x="101" y="96" width="4" height="8" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.7s"/><animate attributeName="height" values="8;14;6;10;8" dur="0.6s" repeatCount="indefinite" begin="1.2s"/><animate attributeName="y" values="96;93;97;95;96" dur="0.6s" repeatCount="indefinite" begin="1.2s"/></rect>
<rect x="108" y="93" width="4" height="14" rx="1" fill="#06B6D4" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.75s"/><animate attributeName="height" values="14;8;12;16;14" dur="0.55s" repeatCount="indefinite" begin="1.25s"/><animate attributeName="y" values="93;96;94;92;93" dur="0.55s" repeatCount="indefinite" begin="1.25s"/></rect>
<rect x="115" y="95" width="4" height="10" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.8s"/><animate attributeName="height" values="10;16;8;12;10" dur="0.5s" repeatCount="indefinite" begin="1.3s"/><animate attributeName="y" values="95;92;96;94;95" dur="0.5s" repeatCount="indefinite" begin="1.3s"/></rect>
<rect x="85" y="75" width="50" height="50" rx="9" fill="none" stroke="#22D3EE" stroke-width="1.5" opacity="0"><animate attributeName="x" values="85;70" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="y" values="75;60" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="width" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="height" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="rx" values="9;14" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="opacity" values="0.5;0" dur="2s" repeatCount="indefinite" begin="1.5s"/></rect>
</g>
<g clip-path="url(#hpTextReveal)">
<text x="280" y="175" font-family="'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-weight="700" font-size="72" fill="url(#hpShimmer)">Stratum<tspan fill="url(#hpProcessorGrad)">I</tspan>Ops</text>
</g>
<rect x="280" y="115" width="4" height="70" rx="2" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1;1;0;0" dur="0.8s" fill="freeze" begin="1s" keyTimes="0;0.1;0.5;0.51;1"/><animate attributeName="x" values="280;980" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></rect>
<line x1="280" y1="195" x2="280" y2="195" stroke="url(#hpUnderlineGrad)" stroke-width="3" stroke-linecap="round" opacity="0"><animate attributeName="opacity" values="0;0.7" dur="0.1s" fill="freeze" begin="1.4s"/><animate attributeName="x2" values="280;750" dur="0.8s" fill="freeze" begin="1.4s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></line>
</g>
<!-- ─── LEGEND (y=718, x=45, w=270) ─── -->
<rect x="45" y="718" width="270" height="88" rx="6" fill="#0c1018" stroke="#1E293B" stroke-width=".8"/>
<text x="180" y="736" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8" letter-spacing="2">LEGEND</text>
<line x1="55" y1="750" x2="85" y2="750" stroke="#22D3EE" stroke-width="2" stroke-dasharray="6 3"/>
<text x="91" y="754" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">event flow (NATS)</text>
<line x1="55" y1="768" x2="85" y2="768" stroke="#F59E0B" stroke-width="2" stroke-dasharray="6 3"/>
<text x="91" y="772" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">auth / credentials</text>
<line x1="55" y1="786" x2="85" y2="786" stroke="#10B981" stroke-width="2" stroke-dasharray="6 3"/>
<text x="91" y="790" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">execution</text>
<line x1="213" y1="750" x2="243" y2="750" stroke="#A78BFA" stroke-width="2" stroke-dasharray="6 3"/>
<text x="249" y="754" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">state (DB)</text>
<line x1="213" y1="768" x2="243" y2="768" stroke="#06B6D4" stroke-width="2" stroke-dasharray="6 3"/>
<text x="249" y="772" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">OCI / registry</text>
<line x1="213" y1="786" x2="243" y2="786" stroke="#64748B" stroke-width="1.5" stroke-dasharray="3 5"/>
<text x="249" y="790" fill="#94A3B8" font-family="Inter,sans-serif" font-size="9">optional</text>
<!-- ─── BRANDING ─── -->
<text x="1140" y="817" text-anchor="end" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="9">stratumiops · v0.1</text>
</svg>

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -0,0 +1,450 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1600 820" width="1600" height="820">
<defs>
<filter id="glow-c" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="glow-g" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="glow-i" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<marker id="ac" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#22D3EE"/>
</marker>
<marker id="ai" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#6366F1"/>
</marker>
<marker id="ag" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#F59E0B"/>
</marker>
<marker id="agr" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#10B981"/>
</marker>
<marker id="ap" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#A78BFA"/>
</marker>
<marker id="ar" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#F43F5E"/>
</marker>
<marker id="aib" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#818CF8"/>
</marker>
<marker id="ail" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#A5B4FC"/>
</marker>
<marker id="ao" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#F97316"/>
</marker>
<!-- ─── stratumiops-h logo defs ─── -->
<linearGradient id="hpLayerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#4F46E5"/>
</linearGradient>
<linearGradient id="hpProcessorGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#06B6D4"/>
</linearGradient>
<linearGradient id="hpFlowGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="0%;100%;0%" dur="2.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="100%;200%;100%" dur="2.5s" repeatCount="indefinite"/>
</linearGradient>
<linearGradient id="hpUnderlineGrad" x1="280" y1="195" x2="750" y2="195" gradientUnits="userSpaceOnUse">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
</linearGradient>
<linearGradient id="hpShimmer" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="40%" style="stop-color:#6366F1"/>
<stop offset="50%" style="stop-color:#22D3EE"/><stop offset="60%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="-100%;100%" dur="3s" repeatCount="indefinite" begin="2s"/>
<animate attributeName="x2" values="0%;200%" dur="3s" repeatCount="indefinite" begin="2s"/>
</linearGradient>
<filter id="hpProcessorGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="5" result="coloredBlur"><animate attributeName="stdDeviation" values="4;8;4" dur="1.5s" repeatCount="indefinite"/></feGaussianBlur>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="hpNodeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<clipPath id="hpTextReveal">
<rect x="280" y="80" width="0" height="150">
<animate attributeName="width" values="0;800" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
</clipPath>
<path id="hpPathIn1" d="M25 40 Q25 65 55 80 Q85 95 95 100" fill="none"/>
<path id="hpPathIn2" d="M215 40 Q215 65 185 80 Q155 95 145 100" fill="none"/>
<path id="hpPathOut1" d="M95 100 Q85 105 55 120 Q25 135 25 160" fill="none"/>
<path id="hpPathOut2" d="M145 100 Q155 105 185 120 Q215 135 215 160" fill="none"/>
</defs>
<style>
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.65} }
@keyframes nats-glow { 0%,100%{stroke-opacity:1;filter:url(#glow-c)} 50%{stroke-opacity:0.5;filter:none} }
@keyframes auth-glow { 0%,100%{stroke-opacity:1;filter:url(#glow-g)} 50%{stroke-opacity:0.4;filter:none} }
@keyframes flow-dash { to{stroke-dashoffset:-20} }
@keyframes flow-dash-slow { to{stroke-dashoffset:-20} }
.fd { animation: flow-dash 1.2s linear infinite; }
.fds { animation: flow-dash-slow 2s linear infinite; }
.nd { animation: nats-glow 1.8s ease-in-out infinite; }
.ah { animation: auth-glow 2s ease-in-out infinite; }
.p1 { animation: pulse 2.4s ease-in-out 0s infinite; }
.p2 { animation: pulse 2.4s ease-in-out 0.2s infinite; }
.p3 { animation: pulse 2.4s ease-in-out 0.4s infinite; }
.p4 { animation: pulse 2.4s ease-in-out 0.6s infinite; }
.p5 { animation: pulse 2.4s ease-in-out 0.8s infinite; }
.p6 { animation: pulse 2.4s ease-in-out 1.0s infinite; }
.p7 { animation: pulse 2.4s ease-in-out 1.2s infinite; }
.p8 { animation: pulse 2.4s ease-in-out 1.4s infinite; }
.p9 { animation: pulse 2.4s ease-in-out 1.6s infinite; }
.p10 { animation: pulse 2.4s ease-in-out 1.8s infinite; }
.p11 { animation: pulse 2.4s ease-in-out 0.3s infinite; }
.p12 { animation: pulse 2.4s ease-in-out 0.5s infinite; }
</style>
<!-- Background -->
<rect width="1600" height="820" fill="#0F172A"/>
<!-- TITLE BAR -->
<rect x="0" y="0" width="1600" height="2" fill="#22D3EE"/>
<rect x="0" y="0" width="1600" height="99" fill="#0A1020"/>
<text x="800" y="40" font-family="'IBM Plex Mono',monospace" font-size="19" fill="#22D3EE" text-anchor="middle" font-weight="500" letter-spacing="2">STRATUM ORCHESTRATOR · BUILD PIPELINE FLOW</text>
<text x="800" y="66" font-family="'IBM Plex Mono',monospace" font-size="12" fill="#64748B" text-anchor="middle">event: dev.crate.provisioning-cli.modified · trigger → ActionGraph → stages → state → emit</text>
<rect x="0" y="98" width="1600" height="2" fill="#1E293B"/>
<!-- LANE LABEL COLUMN SEPARATOR -->
<line x1="182" y1="100" x2="182" y2="742" stroke="#1E293B" stroke-width="2"/>
<!-- Lane 0: Developer / Forgejo -->
<rect x="0" y="100" width="6" height="80" fill="#F97316"/>
<rect x="6" y="100" width="176" height="80" fill="#F97316" fill-opacity="0.07"/>
<text x="22" y="131" font-family="'Inter',sans-serif" font-size="10" fill="#F97316" font-weight="600">Developer · Forgejo</text>
<text x="22" y="147" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">git push / webhook</text>
<!-- Lane 1: NATS JetStream -->
<rect x="0" y="180" width="6" height="80" fill="#22D3EE"/>
<rect x="6" y="180" width="176" height="80" fill="#22D3EE" fill-opacity="0.05"/>
<text x="22" y="211" font-family="'Inter',sans-serif" font-size="10" fill="#22D3EE" font-weight="600">NATS JetStream</text>
<text x="22" y="227" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">Event Bus</text>
<!-- Lane 2: Orchestrator Planning -->
<rect x="0" y="260" width="6" height="80" fill="#6366F1"/>
<rect x="6" y="260" width="176" height="80" fill="#6366F1" fill-opacity="0.05"/>
<text x="22" y="291" font-family="'Inter',sans-serif" font-size="10" fill="#6366F1" font-weight="600">Orchestrator</text>
<text x="22" y="307" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">Planning</text>
<!-- Lane 3: Auth -->
<rect x="0" y="340" width="6" height="80" fill="#F59E0B"/>
<rect x="6" y="340" width="176" height="80" fill="#F59E0B" fill-opacity="0.05"/>
<text x="22" y="371" font-family="'Inter',sans-serif" font-size="10" fill="#F59E0B" font-weight="600">Auth · NKeys</text>
<text x="22" y="387" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">+ Cedar</text>
<!-- Lane 4: Stage 0 Parallel -->
<rect x="0" y="420" width="6" height="80" fill="#10B981"/>
<rect x="6" y="420" width="176" height="80" fill="#10B981" fill-opacity="0.05"/>
<text x="22" y="451" font-family="'Inter',sans-serif" font-size="10" fill="#10B981" font-weight="600">Stage 0</text>
<text x="22" y="467" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">‖ Parallel</text>
<!-- Lane 5: Stage 1 -->
<rect x="0" y="500" width="6" height="80" fill="#818CF8"/>
<rect x="6" y="500" width="176" height="80" fill="#818CF8" fill-opacity="0.04"/>
<text x="22" y="531" font-family="'Inter',sans-serif" font-size="10" fill="#818CF8" font-weight="600">Stage 1</text>
<text x="22" y="547" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">→ Sequential</text>
<!-- Lane 6: Stage 2 -->
<rect x="0" y="580" width="6" height="80" fill="#A5B4FC"/>
<rect x="6" y="580" width="176" height="80" fill="#A5B4FC" fill-opacity="0.03"/>
<text x="22" y="611" font-family="'Inter',sans-serif" font-size="10" fill="#A5B4FC" font-weight="600">Stage 2</text>
<text x="22" y="627" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">→ Sequential</text>
<!-- Lane 7: SurrealDB -->
<rect x="0" y="660" width="6" height="80" fill="#A78BFA"/>
<rect x="6" y="660" width="176" height="80" fill="#A78BFA" fill-opacity="0.05"/>
<text x="22" y="691" font-family="'Inter',sans-serif" font-size="10" fill="#A78BFA" font-weight="600">SurrealDB</text>
<text x="22" y="707" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">State</text>
<!-- LANE DIVIDERS (content area) -->
<line x1="182" y1="180" x2="1590" y2="180" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="260" x2="1590" y2="260" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="340" x2="1590" y2="340" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="420" x2="1590" y2="420" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="500" x2="1590" y2="500" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="580" x2="1590" y2="580" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="660" x2="1590" y2="660" stroke="#1E293B" stroke-width="1"/>
<line x1="182" y1="740" x2="1590" y2="740" stroke="#1E293B" stroke-width="1"/>
<!-- TIMELINE BASELINE (dashed, very subtle) -->
<path id="tl" d="M200,140 L1560,140" fill="none" stroke="#F97316" stroke-width="1" stroke-dasharray="5,4" opacity="0.2" class="fd"/>
<!-- ANIMATED PARTICLES along timeline -->
<circle r="5" fill="#22D3EE" opacity="0.85" filter="url(#glow-c)">
<animateMotion dur="6s" repeatCount="indefinite" begin="0s">
<mpath xlink:href="#tl"/>
</animateMotion>
</circle>
<circle r="4" fill="#6366F1" opacity="0.7">
<animateMotion dur="6s" repeatCount="indefinite" begin="2s">
<mpath xlink:href="#tl"/>
</animateMotion>
</circle>
<circle r="3.5" fill="#F59E0B" opacity="0.65">
<animateMotion dur="6s" repeatCount="indefinite" begin="4s">
<mpath xlink:href="#tl"/>
</animateMotion>
</circle>
<!-- ========== STEP 1: git push (Developer, x=220, yc=140) ========== -->
<g class="p1">
<circle cx="220" cy="112" r="9" fill="#F97316"/>
<text x="220" y="116" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">1</text>
<rect x="185" y="120" width="70" height="40" rx="5" fill="#F97316" fill-opacity="0.18" stroke="#F97316" stroke-width="1.5"/>
<text x="220" y="136" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F97316" text-anchor="middle" font-weight="500">git push</text>
<text x="220" y="150" font-family="'Inter',sans-serif" font-size="8" fill="#94A3B8" text-anchor="middle">code change</text>
<text x="220" y="170" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#475569" text-anchor="middle">commit: abc123f</text>
</g>
<!-- ========== STEP 2: webhook→NATS diamond (spans Developer/NATS border at y=180, x=330) ========== -->
<g class="nd p2">
<circle cx="330" cy="112" r="9" fill="#F97316"/>
<text x="330" y="116" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">2</text>
<polygon points="330,128 400,178 330,228 260,178" fill="#22D3EE" fill-opacity="0.12" stroke="#22D3EE" stroke-width="1.5"/>
<text x="330" y="163" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">dev.crate.</text>
<text x="330" y="178" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">provisioning-cli</text>
<text x="330" y="193" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">.modified</text>
<rect x="283" y="220" width="96" height="24" rx="3" fill="#0A1020" stroke="#1E293B" stroke-width="1"/>
<text x="330" y="231" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#475569" text-anchor="middle">&#123;"crate":"prvng-cli",</text>
<text x="330" y="241" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#475569" text-anchor="middle">"sha":"abc123f"&#125;</text>
</g>
<!-- ========== STEP 3: pull_batch(10) (Orchestrator, x=450, yc=300) ========== -->
<g class="p3">
<circle cx="450" cy="268" r="9" fill="#6366F1"/>
<text x="450" y="272" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">3</text>
<rect x="415" y="277" width="70" height="44" rx="5" fill="#6366F1" fill-opacity="0.18" stroke="#6366F1" stroke-width="1.5"/>
<text x="450" y="294" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#818CF8" text-anchor="middle" font-weight="500">pull_batch</text>
<text x="450" y="307" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#818CF8" text-anchor="middle">(10)</text>
<text x="450" y="332" font-family="'Inter',sans-serif" font-size="8" fill="#475569" text-anchor="middle">JetStream consumer</text>
</g>
<!-- ========== STEP 4: NKey verify (Auth hexagon, x=565, yc=382) ========== -->
<g class="ah p4">
<circle cx="565" cy="348" r="9" fill="#F59E0B"/>
<text x="565" y="352" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">4</text>
<polygon points="565,352 601,370 601,401 565,419 530,401 530,370" fill="#F59E0B" fill-opacity="0.12" stroke="#F59E0B" stroke-width="1.5"/>
<text x="565" y="381" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">NKey</text>
<text x="565" y="394" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">verify</text>
<text x="565" y="428" font-family="'Inter',sans-serif" font-size="8" fill="#475569" text-anchor="middle">publisher auth</text>
</g>
<!-- ========== STEP 5: ActionGraph query+topo-sort (Orchestrator, x=688, yc=299, wider) ========== -->
<g class="p5">
<circle cx="688" cy="268" r="9" fill="#6366F1"/>
<text x="688" y="272" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">5</text>
<rect x="644" y="277" width="88" height="46" rx="5" fill="#6366F1" fill-opacity="0.22" stroke="#6366F1" stroke-width="2" filter="url(#glow-i)"/>
<text x="688" y="293" font-family="'IBM Plex Mono',monospace" font-size="8.5" fill="#A5B4FC" text-anchor="middle" font-weight="600">ActionGraph</text>
<text x="688" y="306" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#818CF8" text-anchor="middle">query + topo-sort</text>
<!-- topo order annotation -->
<rect x="644" y="330" width="88" height="36" rx="3" fill="#0A1020" stroke="#1E293B" stroke-width="1"/>
<text x="688" y="341" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#475569" text-anchor="middle">[lint-crate, fmt-crate]</text>
<text x="688" y="351" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#475569" text-anchor="middle">→ build-crate</text>
<text x="688" y="361" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#475569" text-anchor="middle">→ install</text>
<!-- Stages bracket -->
<line x1="740" y1="277" x2="740" y2="502" stroke="#334155" stroke-width="1" stroke-dasharray="3,3"/>
<text x="754" y="396" font-family="'Inter',sans-serif" font-size="8" fill="#334155" text-anchor="middle" transform="rotate(90,754,396)">Stages</text>
</g>
<!-- ========== STEP 6: Cedar authorize (Auth hexagon, x=800, yc=382) ========== -->
<g class="ah p6">
<circle cx="800" cy="348" r="9" fill="#F59E0B"/>
<text x="800" y="352" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">6</text>
<polygon points="800,352 836,370 836,401 800,419 765,401 765,370" fill="#F59E0B" fill-opacity="0.12" stroke="#F59E0B" stroke-width="1.5"/>
<text x="800" y="381" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">Cedar</text>
<text x="800" y="394" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">authorize</text>
<text x="800" y="425" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#475569" text-anchor="middle">permit: trigger</text>
<text x="800" y="435" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#475569" text-anchor="middle">node:build-crate</text>
</g>
<!-- ========== STEP 7: Stage 0 PARALLEL wide box (Stage 0, x=876-1036, yc=460) ========== -->
<!-- parallel bracket above -->
<path d="M878,428 L878,423 L1038,423 L1038,428" fill="none" stroke="#10B981" stroke-width="1.2" stroke-dasharray="3,2"/>
<text x="958" y="420" font-family="'Inter',sans-serif" font-size="9" fill="#10B981" text-anchor="middle" font-weight="600">‖ parallel · JoinSet</text>
<g class="p7">
<circle cx="878" cy="430" r="9" fill="#10B981"/>
<text x="878" y="434" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">7</text>
<!-- wide rect -->
<rect x="878" y="437" width="160" height="44" rx="5" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1.5"/>
<!-- center divider -->
<line x1="958" y1="437" x2="958" y2="481" stroke="#10B981" stroke-width="1" stroke-dasharray="3,2" opacity="0.6"/>
<!-- LEFT: lint.nu -->
<text x="918" y="455" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#10B981" text-anchor="middle" font-weight="500">lint.nu</text>
<text x="918" y="469" font-family="'Inter',sans-serif" font-size="8" fill="#94A3B8" text-anchor="middle">(clippy)</text>
<!-- RIGHT: fmt.nu -->
<text x="998" y="455" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#10B981" text-anchor="middle" font-weight="500">fmt.nu</text>
<text x="998" y="469" font-family="'Inter',sans-serif" font-size="8" fill="#94A3B8" text-anchor="middle">(cargo fmt)</text>
</g>
<!-- join arc below -->
<path d="M878,481 L878,494 L1038,494 L1038,481" fill="none" stroke="#10B981" stroke-width="1" stroke-dasharray="3,2" opacity="0.5"/>
<text x="958" y="506" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#475569" text-anchor="middle">join_all → PipelineContext</text>
<!-- ========== STEP 8: deposit capabilities (Orchestrator, x=1075, yc=300) ========== -->
<g class="p8">
<circle cx="1075" cy="268" r="9" fill="#6366F1"/>
<text x="1075" y="272" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">8</text>
<rect x="1038" y="277" width="74" height="46" rx="5" fill="#6366F1" fill-opacity="0.15" stroke="#6366F1" stroke-width="1.5"/>
<text x="1075" y="293" font-family="'IBM Plex Mono',monospace" font-size="8.5" fill="#818CF8" text-anchor="middle" font-weight="500">deposit:</text>
<text x="1075" y="307" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#94A3B8" text-anchor="middle">linted-code</text>
<text x="1075" y="318" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#94A3B8" text-anchor="middle">formatted-code</text>
</g>
<!-- ========== STEP 9: Stage 1: build.nu (Stage 1, x=1190, yc=540) ========== -->
<g class="p9">
<circle cx="1190" cy="508" r="9" fill="#818CF8"/>
<text x="1190" y="512" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">9</text>
<rect x="1153" y="517" width="74" height="44" rx="5" fill="#818CF8" fill-opacity="0.15" stroke="#818CF8" stroke-width="1.5"/>
<text x="1190" y="533" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#818CF8" text-anchor="middle" font-weight="500">build.nu</text>
<text x="1190" y="546" font-family="'Inter',sans-serif" font-size="8" fill="#94A3B8" text-anchor="middle">(cargo build)</text>
<!-- Vault annotation -->
<rect x="1153" y="567" width="74" height="22" rx="3" fill="#0A1020" stroke="#F59E0B" stroke-width="1" stroke-dasharray="3,2"/>
<text x="1190" y="578" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#F59E0B" text-anchor="middle">&#x1F510; Vault cred</text>
<text x="1190" y="588" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#F59E0B" text-anchor="middle">TTL=step timeout</text>
<text x="1190" y="603" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#475569" text-anchor="middle">deposit: built-artifact</text>
</g>
<!-- ========== STEP 10: Stage 2: install.nu (Stage 2, x=1310, yc=620) ========== -->
<g class="p10">
<circle cx="1310" cy="588" r="9" fill="#A5B4FC"/>
<text x="1310" y="592" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">10</text>
<rect x="1263" y="597" width="94" height="44" rx="5" fill="#A5B4FC" fill-opacity="0.12" stroke="#A5B4FC" stroke-width="1.5"/>
<text x="1310" y="613" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#A5B4FC" text-anchor="middle" font-weight="500">install.nu</text>
<text x="1310" y="625" font-family="'Inter',sans-serif" font-size="8" fill="#94A3B8" text-anchor="middle">extracts: built-artifact</text>
<text x="1310" y="650" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#475569" text-anchor="middle">deposit: installed</text>
</g>
<!-- ========== STEP 11: pipeline_run → SurrealDB (SurrealDB, x=1420, yc=700) ========== -->
<g class="p11">
<circle cx="1420" cy="668" r="9" fill="#A78BFA"/>
<text x="1420" y="672" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">11</text>
<rect x="1373" y="677" width="94" height="44" rx="5" fill="#A78BFA" fill-opacity="0.15" stroke="#A78BFA" stroke-width="1.5"/>
<text x="1420" y="693" font-family="'IBM Plex Mono',monospace" font-size="8.5" fill="#A78BFA" text-anchor="middle" font-weight="500">pipeline_run</text>
<text x="1420" y="706" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#94A3B8" text-anchor="middle">status: success</text>
<text x="1420" y="727" font-family="'Inter',sans-serif" font-size="7.5" fill="#475569" text-anchor="middle">all steps persisted</text>
</g>
<!-- ========== STEP 12: emit result event (NATS diamond, x=1530, yc=220) ========== -->
<g class="nd p12">
<circle cx="1530" cy="178" r="9" fill="#22D3EE"/>
<text x="1530" y="182" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">12</text>
<polygon points="1530,185 1592,220 1530,255 1468,220" fill="#22D3EE" fill-opacity="0.12" stroke="#22D3EE" stroke-width="1.5"/>
<text x="1530" y="208" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">dev.crate.</text>
<text x="1530" y="221" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">provisioning-cli</text>
<text x="1530" y="234" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">.built</text>
<text x="1530" y="264" font-family="'Inter',sans-serif" font-size="7.5" fill="#475569" text-anchor="middle">duration_ms, status</text>
</g>
<!-- ========== FLOW ARROWS ========== -->
<!-- 1→2: git push triggers webhook (Developer lane →) -->
<line x1="255" y1="140" x2="292" y2="162" stroke="#F97316" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#ao)" class="fd"/>
<!-- 2→3: NATS event received (cross-lane: NATS down to Orchestrator) -->
<line x1="330" y1="216" x2="330" y2="270" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ai)"/>
<line x1="330" y1="299" x2="415" y2="299" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#ai)" class="fds"/>
<!-- 3→4: Orchestrator requests NKey verify (cross-lane down to Auth) -->
<line x1="487" y1="299" x2="525" y2="365" stroke="#F59E0B" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ag)"/>
<!-- 4→5: Auth ok, Orchestrator proceeds (cross-lane back up + forward) -->
<line x1="606" y1="385" x2="644" y2="299" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ai)"/>
<!-- 5→6: ActionGraph built, Cedar authorize -->
<line x1="732" y1="300" x2="760" y2="365" stroke="#F59E0B" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ag)"/>
<!-- 6→7: Auth permits, launch Stage 0 -->
<line x1="833" y1="398" x2="878" y2="445" stroke="#10B981" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#agr)"/>
<!-- 7→8: Stage 0 join → deposit to Orchestrator -->
<line x1="1038" y1="458" x2="1075" y2="323" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ai)"/>
<!-- 8→9: Deposit done → Stage 1 -->
<line x1="1075" y1="323" x2="1153" y2="517" stroke="#818CF8" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#aib)"/>
<!-- 9→10: Stage 1 done → Stage 2 -->
<line x1="1227" y1="540" x2="1263" y2="617" stroke="#A5B4FC" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ail)"/>
<!-- 10→11: Stage 2 done → SurrealDB ACK -->
<line x1="1310" y1="641" x2="1373" y2="693" stroke="#A78BFA" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ap)"/>
<!-- 11→12: SurrealDB persisted → emit result NATS -->
<path d="M1420,677 L1420,220 L1474,220" fill="none" stroke="#22D3EE" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#ac)" class="fds"/>
<!-- Orchestrator ↔ SurrealDB (bidirectional state) -->
<path d="M1075,323 Q1075,700 1373,700" fill="none" stroke="#A78BFA" stroke-width="1" stroke-dasharray="3,3" opacity="0.4"/>
<!-- ========== ROLLBACK PATH ========== -->
<path d="M1153,574 L800,574" fill="none" stroke="#F43F5E" stroke-width="2" stroke-dasharray="7,4" marker-end="url(#ar)" opacity="0.85"/>
<rect x="850" y="553" width="210" height="17" rx="3" fill="#0A1020" fill-opacity="0.95"/>
<text x="955" y="565" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#F43F5E" text-anchor="middle">rollback path: compensate.nu in reverse order</text>
<!-- ========== LOGO above LEGEND ========== -->
<g transform="translate(10,15) scale(0.2)">
<g transform="translate(20,50)">
<rect x="-220" y="18" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.5;0.8;0.5" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<rect x="-220" y="88" width="220" height="24" rx="5" fill="url(#hpLayerGrad)">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.15s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
<rect x="-220" y="158" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.3s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.8;0.5;0.8" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<path d="M35 42 Q35 65 70 85 Q95 100 95 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.5s"/></path>
<path d="M185 42 Q185 65 150 85 Q125 100 125 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.6s"/></path>
<path d="M95 100 Q95 100 70 115 Q35 135 35 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.7s"/></path>
<path d="M125 100 Q125 100 150 115 Q185 135 185 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.8s"/></path>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.5s"><mpath href="#hpPathIn1"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.8s"><mpath href="#hpPathIn2"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.1s"><mpath href="#hpPathOut1"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.1s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.4s"><mpath href="#hpPathOut2"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.4s"/></circle>
<circle cx="35" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.25s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="185" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.3s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="35" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.4s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle cx="185" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.45s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<rect x="85" y="75" width="50" height="50" rx="9" fill="url(#hpProcessorGrad)" filter="url(#hpProcessorGlow)" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.3s" fill="freeze" begin="0.45s"/></rect>
<rect x="97" y="87" width="26" height="26" rx="5" fill="#ffffff" opacity="0"><animate attributeName="opacity" values="0;0.95" dur="0.25s" fill="freeze" begin="0.55s"/></rect>
<rect x="101" y="96" width="4" height="8" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.7s"/><animate attributeName="height" values="8;14;6;10;8" dur="0.6s" repeatCount="indefinite" begin="1.2s"/><animate attributeName="y" values="96;93;97;95;96" dur="0.6s" repeatCount="indefinite" begin="1.2s"/></rect>
<rect x="108" y="93" width="4" height="14" rx="1" fill="#06B6D4" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.75s"/><animate attributeName="height" values="14;8;12;16;14" dur="0.55s" repeatCount="indefinite" begin="1.25s"/><animate attributeName="y" values="93;96;94;92;93" dur="0.55s" repeatCount="indefinite" begin="1.25s"/></rect>
<rect x="115" y="95" width="4" height="10" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.8s"/><animate attributeName="height" values="10;16;8;12;10" dur="0.5s" repeatCount="indefinite" begin="1.3s"/><animate attributeName="y" values="95;92;96;94;95" dur="0.5s" repeatCount="indefinite" begin="1.3s"/></rect>
<rect x="85" y="75" width="50" height="50" rx="9" fill="none" stroke="#22D3EE" stroke-width="1.5" opacity="0"><animate attributeName="x" values="85;70" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="y" values="75;60" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="width" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="height" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="rx" values="9;14" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="opacity" values="0.5;0" dur="2s" repeatCount="indefinite" begin="1.5s"/></rect>
</g>
<g clip-path="url(#hpTextReveal)">
<text x="280" y="175" font-family="'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-weight="700" font-size="72" fill="url(#hpShimmer)">Stratum<tspan fill="url(#hpProcessorGrad)">I</tspan>Ops</text>
</g>
<rect x="280" y="115" width="4" height="70" rx="2" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1;1;0;0" dur="0.8s" fill="freeze" begin="1s" keyTimes="0;0.1;0.5;0.51;1"/><animate attributeName="x" values="280;980" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></rect>
<line x1="280" y1="195" x2="280" y2="195" stroke="url(#hpUnderlineGrad)" stroke-width="3" stroke-linecap="round" opacity="0"><animate attributeName="opacity" values="0;0.7" dur="0.1s" fill="freeze" begin="1.4s"/><animate attributeName="x2" values="280;750" dur="0.8s" fill="freeze" begin="1.4s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></line>
</g>
<!-- ========== LEGEND (y=760-800) ========== -->
<text x="192" y="770" font-family="'Inter',sans-serif" font-size="9" fill="#475569" font-weight="600">Legend:</text>
<rect x="240" y="759" width="11" height="11" rx="2" fill="#22D3EE" fill-opacity="0.25" stroke="#22D3EE" stroke-width="1"/>
<text x="256" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#94A3B8">NATS events</text>
<rect x="335" y="759" width="11" height="11" rx="2" fill="#6366F1" fill-opacity="0.25" stroke="#6366F1" stroke-width="1"/>
<text x="351" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#94A3B8">Orchestrator</text>
<rect x="434" y="759" width="11" height="11" rx="2" fill="#F59E0B" fill-opacity="0.25" stroke="#F59E0B" stroke-width="1"/>
<text x="450" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#94A3B8">Auth (NKeys + Cedar)</text>
<rect x="578" y="759" width="11" height="11" rx="2" fill="#10B981" fill-opacity="0.25" stroke="#10B981" stroke-width="1"/>
<text x="594" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#94A3B8">Execution (Stage 0)</text>
<rect x="726" y="759" width="11" height="11" rx="2" fill="#A78BFA" fill-opacity="0.25" stroke="#A78BFA" stroke-width="1"/>
<text x="742" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#94A3B8">State (SurrealDB)</text>
<line x1="852" y1="765" x2="875" y2="765" stroke="#F43F5E" stroke-width="2" stroke-dasharray="5,3"/>
<text x="882" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#94A3B8">Rollback path</text>
<text x="1588" y="775" font-family="'Inter',sans-serif" font-size="8" fill="#374151" text-anchor="end">StratumIOps · stratum-orchestrator</text>
<text x="1588" y="789" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#374151" text-anchor="end">stratum-orchestrator · build pipeline</text>
</svg>

After

Width:  |  Height:  |  Size: 36 KiB

97
assets/orchestrator/minify.sh Executable file
View File

@ -0,0 +1,97 @@
#!/bin/bash
# Minify HTML files from src/ to production versions
# Usage: ./minify.sh
# Processes: stratumiops-arch, stratumiops-pipeline, stratumiops-orchestrator
set -e
SCRIPT_DIR="$(dirname "$0")"
FILES=("stratumiops-arch" "stratumiops-pipeline" "stratumiops-orchestrator")
minify_file() {
local basename=$1
local src_file="${SCRIPT_DIR}/src/${basename}.html"
local out_file="${SCRIPT_DIR}/${basename}.html"
local temp_file="${out_file}.tmp"
if [ ! -f "$src_file" ]; then
echo "⚠️ Skipping $basename: source file not found: $src_file"
return 0
fi
echo "🔨 Minifying $basename.html..."
echo " Input: $src_file"
echo " Output: $out_file"
perl -e "
use strict;
use warnings;
open(my \$fh, '<', '$src_file') or die \$!;
my \$content = do { local \$/; <\$fh> };
close(\$fh);
# Remove HTML comments
\$content =~ s/<!--.*?-->//gs;
# Compress CSS (remove spaces and comments)
\$content =~ s/(<style[^>]*>)(.*?)(<\/style>)/
my \$before = \$1;
my \$style = \$2;
my \$after = \$3;
\$style =~ s{\/\*.*?\*\/}{}gs;
\$style =~ s{\s+}{ }gs;
\$style =~ s{\s*([{}:;,>+~])\s*}{\$1}gs;
\$before . \$style . \$after;
/gies;
# Compress JavaScript (remove comments and extra spaces)
\$content =~ s/(<script[^>]*>)(.*?)(<\/script>)/
my \$before = \$1;
my \$script = \$2;
my \$after = \$3;
\$script =~ s{\/\/.*\$}{}gm;
\$script =~ s{\s+}{ }gs;
\$script =~ s{\s*([{}();,])\s*}{\$1}gs;
\$before . \$script . \$after;
/gies;
# Remove whitespace between tags
\$content =~ s/>\s+</></gs;
# Compress general whitespace
\$content =~ s/\s+/ /gs;
# Trim
\$content =~ s/^\s+|\s+\$//g;
open(my \$out, '>', '$temp_file') or die \$!;
print \$out \$content;
close(\$out);
" || {
echo "❌ Minification failed for $basename"
rm -f "$temp_file"
return 1
}
mv "$temp_file" "$out_file"
# Show statistics
original=$(wc -c < "$src_file")
minified=$(wc -c < "$out_file")
saved=$((original - minified))
percent=$((saved * 100 / original))
echo "$basename.html minified"
printf " Original: %6d bytes | Minified: %6d bytes | Saved: %d%% (%d bytes)\n" "$original" "$minified" "$percent" "$saved"
echo ""
}
echo "🔨 Minifying HTML files..."
echo ""
for file in "${FILES[@]}"; do
minify_file "$file" || exit 1
done
echo "✅ All HTML files minified and ready for production"

View File

@ -0,0 +1,185 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StratumIOps — Orchestrator Architecture</title>
<style>
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=Inter:wght@300;400;500;600;700&display=swap");
:root {
--bg: #0a0c10;
--text: #c8ccd4;
--border: rgba(255, 255, 255, 0.1);
--pill-bg: rgba(15, 23, 42, 0.85);
--pill-border: rgba(255, 255, 255, 0.12);
}
html.light {
--bg: #eef2f7;
--text: #1e293b;
--border: rgba(0, 0, 0, 0.1);
--pill-bg: rgba(241, 245, 249, 0.92);
--pill-border: rgba(0, 0, 0, 0.12);
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg);
color: var(--text);
font-family: "Inter", sans-serif;
height: 100vh;
width: 100vw;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
.diagram-container {
/* arch viewBox 1170×830 */
width: min(100vw, calc(100vh * 1170 / 830));
height: min(100vh, calc(100vw * 830 / 1170));
position: relative;
}
.diagram-container img {
width: 100%;
height: 100%;
display: block;
}
.nav {
position: fixed;
top: 1.25rem;
right: 1.25rem;
z-index: 100;
display: flex;
align-items: center;
gap: 0.375rem;
background: var(--pill-bg);
border: 1px solid var(--pill-border);
border-radius: 24px;
padding: 0.3rem 0.4rem;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.nav a,
.nav button {
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.42rem 0.9rem;
border-radius: 18px;
border: none;
cursor: pointer;
text-decoration: none;
color: #94a3b8;
background: transparent;
transition:
color 0.25s,
background 0.25s;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.nav a:hover,
.nav button:hover {
color: #22d3ee;
}
.nav a.active {
background: linear-gradient(135deg, #6366f1, #22d3ee);
color: #fff;
}
.nav .sep {
width: 1px;
height: 20px;
background: var(--pill-border);
}
.nav .theme-btn {
font-size: 1.1rem;
padding: 0.42rem 0.6rem;
color: var(--text);
}
@media (max-width: 640px) {
.nav {
top: 0.75rem;
right: 0.75rem;
}
.nav a,
.nav button {
padding: 0.35rem 0.65rem;
font-size: 0.72rem;
}
}
</style>
</head>
<body>
<div class="nav">
<button
class="theme-btn"
id="theme-btn"
onclick="toggleTheme()"
title="Toggle light/dark"
>
🌙
</button>
<div class="sep"></div>
<a href="stratumiops-orchestrator.html" title="Stratum">Stratum →</a>
</div>
<div class="diagram-container">
<img
id="dark-svg"
src="arch-stratum-orchestrator.svg"
alt="Stratum Orchestrator Architecture — Dark"
/>
<img
id="light-svg"
src="w-arch-stratum-orchestrator.svg"
alt="Stratum Orchestrator Architecture — Light"
style="display: none"
/>
</div>
<script>
const KEY = "stratumiops-theme";
function applyTheme(t) {
const isDark = t !== "light";
document.documentElement.classList.toggle("light", !isDark);
document.getElementById("dark-svg").style.display = isDark
? "block"
: "none";
document.getElementById("light-svg").style.display = isDark
? "none"
: "block";
document.getElementById("theme-btn").textContent = isDark ? "☀️" : "🌙";
localStorage.setItem(KEY, t);
}
function toggleTheme() {
applyTheme(localStorage.getItem(KEY) === "light" ? "dark" : "light");
}
applyTheme(localStorage.getItem(KEY) || "dark");
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,186 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StratumIOps — Build Pipeline Flow</title>
<style>
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=Inter:wght@300;400;500;600;700&display=swap");
:root {
--bg: #0a0c10;
--text: #c8ccd4;
--border: rgba(255, 255, 255, 0.1);
--pill-bg: rgba(15, 23, 42, 0.85);
--pill-border: rgba(255, 255, 255, 0.12);
}
html.light {
--bg: #eef2f7;
--text: #1e293b;
--border: rgba(0, 0, 0, 0.1);
--pill-bg: rgba(241, 245, 249, 0.92);
--pill-border: rgba(0, 0, 0, 0.12);
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg);
color: var(--text);
font-family: "Inter", sans-serif;
height: 100vh;
width: 100vw;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
.diagram-container {
/* pipeline viewBox 1600×820 */
width: min(100vw, calc(100vh * 1600 / 820));
height: min(100vh, calc(100vw * 820 / 1600));
position: relative;
}
.diagram-container img {
width: 100%;
height: 100%;
display: block;
}
.nav {
position: fixed;
top: 1.25rem;
right: 1.25rem;
z-index: 100;
display: flex;
align-items: center;
gap: 0.375rem;
background: var(--pill-bg);
border: 1px solid var(--pill-border);
border-radius: 24px;
padding: 0.3rem 0.4rem;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.nav a,
.nav button {
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.42rem 0.9rem;
border-radius: 18px;
border: none;
cursor: pointer;
text-decoration: none;
color: #94a3b8;
background: transparent;
transition:
color 0.25s,
background 0.25s;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.nav a:hover,
.nav button:hover {
color: #22d3ee;
}
.nav a.active {
background: linear-gradient(135deg, #6366f1, #22d3ee);
color: #fff;
}
.nav .sep {
width: 1px;
height: 20px;
background: var(--pill-border);
}
.nav .theme-btn {
font-size: 1.1rem;
padding: 0.42rem 0.6rem;
color: var(--text);
}
@media (max-width: 640px) {
.nav {
top: 0.75rem;
right: 0.75rem;
}
.nav a,
.nav button {
padding: 0.35rem 0.65rem;
font-size: 0.72rem;
}
}
</style>
</head>
<body>
<div class="nav">
<button
class="theme-btn"
id="theme-btn"
onclick="toggleTheme()"
title="Toggle light/dark"
>
🌙
</button>
<div class="sep"></div>
<a href="stratumiops-orchestrator.html" title="Stratum">← Stratum</a>
>
</div>
<div class="diagram-container">
<img
id="dark-svg"
src="flow-stratum-build-pipeline.svg"
alt="Stratum Build Pipeline Flow — Dark"
/>
<img
id="light-svg"
src="w-flow-stratum-build-pipeline.svg"
alt="Stratum Build Pipeline Flow — Light"
style="display: none"
/>
</div>
<script>
const KEY = "stratumiops-theme";
function applyTheme(t) {
const isDark = t !== "light";
document.documentElement.classList.toggle("light", !isDark);
document.getElementById("dark-svg").style.display = isDark
? "block"
: "none";
document.getElementById("light-svg").style.display = isDark
? "none"
: "block";
document.getElementById("theme-btn").textContent = isDark ? "☀️" : "🌙";
localStorage.setItem(KEY, t);
}
function toggleTheme() {
applyTheme(localStorage.getItem(KEY) === "light" ? "dark" : "light");
}
applyTheme(localStorage.getItem(KEY) || "dark");
</script>
</body>
</html>

View File

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>StratumIOps — Orchestrator Architecture</title><style> @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=Inter:wght@300;400;500;600;700&display=swap");:root{--bg:#0a0c10;--text:#c8ccd4;--border:rgba(255,255,255,0.1);--pill-bg:rgba(15,23,42,0.85);--pill-border:rgba(255,255,255,0.12);}html.light{--bg:#eef2f7;--text:#1e293b;--border:rgba(0,0,0,0.1);--pill-bg:rgba(241,245,249,0.92);--pill-border:rgba(0,0,0,0.12);}*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;}body{background:var(--bg);color:var(--text);font-family:"Inter",sans-serif;height:100vh;width:100vw;display:flex;align-items:center;justify-content:center;overflow:hidden;transition:background-color 0.3s ease,color 0.3s ease;}.diagram-container{width:min(100vw,calc(100vh * 1170 / 830));height:min(100vh,calc(100vw * 830 / 1170));position:relative;}.diagram-container img{width:100%;height:100%;display:block;}.nav{position:fixed;top:1.25rem;right:1.25rem;z-index:100;display:flex;align-items:center;gap:0.375rem;background:var(--pill-bg);border:1px solid var(--pill-border);border-radius:24px;padding:0.3rem 0.4rem;backdrop-filter:blur(12px);box-shadow:0 8px 32px rgba(0,0,0,0.18);}.nav a,.nav button{font-family:"IBM Plex Mono",monospace;font-size:0.78rem;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;padding:0.42rem 0.9rem;border-radius:18px;border:none;cursor:pointer;text-decoration:none;color:#94a3b8;background:transparent;transition:color 0.25s,background 0.25s;display:inline-flex;align-items:center;gap:0.3rem;}.nav a:hover,.nav button:hover{color:#22d3ee;}.nav a.active{background:linear-gradient(135deg,#6366f1,#22d3ee);color:#fff;}.nav .sep{width:1px;height:20px;background:var(--pill-border);}.nav .theme-btn{font-size:1.1rem;padding:0.42rem 0.6rem;color:var(--text);}@media (max-width:640px){.nav{top:0.75rem;right:0.75rem;}.nav a,.nav button{padding:0.35rem 0.65rem;font-size:0.72rem;}}</style></head><body><div class="nav"><button class="theme-btn" id="theme-btn" onclick="toggleTheme()" title="Toggle light/dark" > 🌙 </button><div class="sep"></div><a href="stratumiops-orchestrator.html" title="Stratum">Stratum →</a></div><div class="diagram-container"><img id="dark-svg" src="arch-stratum-orchestrator.svg" alt="Stratum Orchestrator Architecture — Dark" /><img id="light-svg" src="w-arch-stratum-orchestrator.svg" alt="Stratum Orchestrator Architecture — Light" style="display: none" /></div><script> const KEY = "stratumiops-theme";function applyTheme(t){const isDark = t !== "light";document.documentElement.classList.toggle("light",!isDark);document.getElementById("dark-svg").style.display = isDark ? "block" : "none";document.getElementById("light-svg").style.display = isDark ? "none" : "block";document.getElementById("theme-btn").textContent = isDark ? "☀️" : "🌙";localStorage.setItem(KEY,t);}function toggleTheme(){applyTheme(localStorage.getItem(KEY)=== "light" ? "dark" : "light");}applyTheme(localStorage.getItem(KEY)|| "dark");</script></body></html>

View File

@ -0,0 +1,191 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 300" width="800" height="300">
<defs>
<linearGradient id="hpLayerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366F1"/>
<stop offset="100%" style="stop-color:#4F46E5"/>
</linearGradient>
<linearGradient id="hpProcessorGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#22D3EE"/>
<stop offset="100%" style="stop-color:#06B6D4"/>
</linearGradient>
<linearGradient id="hpFlowGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/>
<stop offset="50%" style="stop-color:#22D3EE"/>
<stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="0%;100%;0%" dur="2.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="100%;200%;100%" dur="2.5s" repeatCount="indefinite"/>
</linearGradient>
<linearGradient id="hpUnderlineGrad" x1="280" y1="195" x2="750" y2="195" gradientUnits="userSpaceOnUse">
<stop offset="0%" style="stop-color:#6366F1"/>
<stop offset="50%" style="stop-color:#22D3EE"/>
<stop offset="100%" style="stop-color:#6366F1"/>
</linearGradient>
<linearGradient id="hpShimmer" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/>
<stop offset="40%" style="stop-color:#6366F1"/>
<stop offset="50%" style="stop-color:#22D3EE"/>
<stop offset="60%" style="stop-color:#6366F1"/>
<stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="-100%;100%" dur="3s" repeatCount="indefinite" begin="2s"/>
<animate attributeName="x2" values="0%;200%" dur="3s" repeatCount="indefinite" begin="2s"/>
</linearGradient>
<filter id="hpProcessorGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="5" result="coloredBlur">
<animate attributeName="stdDeviation" values="4;8;4" dur="1.5s" repeatCount="indefinite"/>
</feGaussianBlur>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<filter id="hpNodeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<clipPath id="hpTextReveal">
<rect x="280" y="80" width="0" height="150">
<animate attributeName="width" values="0;800" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
</clipPath>
<path id="hpPathIn1" d="M25 40 Q25 65 55 80 Q85 95 95 100" fill="none"/>
<path id="hpPathIn2" d="M215 40 Q215 65 185 80 Q155 95 145 100" fill="none"/>
<path id="hpPathOut1" d="M95 100 Q85 105 55 120 Q25 135 25 160" fill="none"/>
<path id="hpPathOut2" d="M145 100 Q155 105 185 120 Q215 135 215 160" fill="none"/>
<style>
.hp-wordmark { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-weight: 700; }
</style>
</defs>
<!-- Logo Icon -->
<g transform="translate(20, 50)">
<!-- Layers -->
<rect x="-220" y="18" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.5;0.8;0.5" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<rect x="-220" y="88" width="220" height="24" rx="5" fill="url(#hpLayerGrad)">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.15s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
<rect x="-220" y="158" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.3s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.8;0.5;0.8" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<!-- Flows -->
<path d="M35 42 Q35 65 70 85 Q95 100 95 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round">
<animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.5s"/>
</path>
<path d="M185 42 Q185 65 150 85 Q125 100 125 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round">
<animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.6s"/>
</path>
<path d="M95 100 Q95 100 70 115 Q35 135 35 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round">
<animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.7s"/>
</path>
<path d="M125 100 Q125 100 150 115 Q185 135 185 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round">
<animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.8s"/>
</path>
<!-- Particles -->
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)">
<animateMotion dur="1.2s" repeatCount="indefinite" begin="1.5s">
<mpath href="#hpPathIn1"/>
</animateMotion>
<animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.5s"/>
</circle>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)">
<animateMotion dur="1.2s" repeatCount="indefinite" begin="1.8s">
<mpath href="#hpPathIn2"/>
</animateMotion>
<animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.8s"/>
</circle>
<circle r="3" fill="#6366F1">
<animateMotion dur="1.2s" repeatCount="indefinite" begin="2.1s">
<mpath href="#hpPathOut1"/>
</animateMotion>
<animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.1s"/>
</circle>
<circle r="3" fill="#6366F1">
<animateMotion dur="1.2s" repeatCount="indefinite" begin="2.4s">
<mpath href="#hpPathOut2"/>
</animateMotion>
<animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.4s"/>
</circle>
<!-- I/O Points -->
<circle cx="35" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)">
<animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.25s"/>
<animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.5s"/>
</circle>
<circle cx="185" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)">
<animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.3s"/>
<animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.5s"/>
</circle>
<circle cx="35" cy="170" r="0" fill="#6366F1">
<animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.4s"/>
<animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.8s"/>
</circle>
<circle cx="185" cy="170" r="0" fill="#6366F1">
<animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.45s"/>
<animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.8s"/>
</circle>
<!-- Central processor -->
<rect x="85" y="75" width="50" height="50" rx="9" fill="url(#hpProcessorGrad)" filter="url(#hpProcessorGlow)" opacity="0">
<animate attributeName="opacity" values="0;1" dur="0.3s" fill="freeze" begin="0.45s"/>
</rect>
<rect x="97" y="87" width="26" height="26" rx="5" fill="#ffffff" opacity="0">
<animate attributeName="opacity" values="0;0.95" dur="0.25s" fill="freeze" begin="0.55s"/>
</rect>
<!-- Equalizer bars -->
<rect x="101" y="96" width="4" height="8" rx="1" fill="#22D3EE" opacity="0">
<animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.7s"/>
<animate attributeName="height" values="8;14;6;10;8" dur="0.6s" repeatCount="indefinite" begin="1.2s"/>
<animate attributeName="y" values="96;93;97;95;96" dur="0.6s" repeatCount="indefinite" begin="1.2s"/>
</rect>
<rect x="108" y="93" width="4" height="14" rx="1" fill="#06B6D4" opacity="0">
<animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.75s"/>
<animate attributeName="height" values="14;8;12;16;14" dur="0.55s" repeatCount="indefinite" begin="1.25s"/>
<animate attributeName="y" values="93;96;94;92;93" dur="0.55s" repeatCount="indefinite" begin="1.25s"/>
</rect>
<rect x="115" y="95" width="4" height="10" rx="1" fill="#22D3EE" opacity="0">
<animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.8s"/>
<animate attributeName="height" values="10;16;8;12;10" dur="0.5s" repeatCount="indefinite" begin="1.3s"/>
<animate attributeName="y" values="95;92;96;94;95" dur="0.5s" repeatCount="indefinite" begin="1.3s"/>
</rect>
<!-- Processor pulse -->
<rect x="85" y="75" width="50" height="50" rx="9" fill="none" stroke="#22D3EE" stroke-width="1.5" opacity="0">
<animate attributeName="x" values="85;70" dur="2s" repeatCount="indefinite" begin="1.5s"/>
<animate attributeName="y" values="75;60" dur="2s" repeatCount="indefinite" begin="1.5s"/>
<animate attributeName="width" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/>
<animate attributeName="height" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/>
<animate attributeName="rx" values="9;14" dur="2s" repeatCount="indefinite" begin="1.5s"/>
<animate attributeName="opacity" values="0.5;0" dur="2s" repeatCount="indefinite" begin="1.5s"/>
</rect>
</g>
<!-- Wordmark -->
<g clip-path="url(#hpTextReveal)">
<text x="280" y="175" class="hp-wordmark" font-size="72" fill="url(#hpShimmer)">
Stratum<tspan fill="url(#hpProcessorGrad)">I</tspan>Ops
</text>
</g>
<!-- Cursor -->
<rect x="280" y="115" width="4" height="70" rx="2" fill="#22D3EE" opacity="0">
<animate attributeName="opacity" values="0;1;1;0;0" dur="0.8s" fill="freeze" begin="1s" keyTimes="0;0.1;0.5;0.51;1"/>
<animate attributeName="x" values="280;980" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
<!-- Underline -->
<line x1="280" y1="195" x2="280" y2="195" stroke="url(#hpUnderlineGrad)" stroke-width="3" stroke-linecap="round" opacity="0">
<animate attributeName="opacity" values="0;0.7" dur="0.1s" fill="freeze" begin="1.4s"/>
<animate attributeName="x2" values="280;750" dur="0.8s" fill="freeze" begin="1.4s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</line>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>StratumIOps — Build Pipeline Flow</title><style> @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=Inter:wght@300;400;500;600;700&display=swap");:root{--bg:#0a0c10;--text:#c8ccd4;--border:rgba(255,255,255,0.1);--pill-bg:rgba(15,23,42,0.85);--pill-border:rgba(255,255,255,0.12);}html.light{--bg:#eef2f7;--text:#1e293b;--border:rgba(0,0,0,0.1);--pill-bg:rgba(241,245,249,0.92);--pill-border:rgba(0,0,0,0.12);}*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;}body{background:var(--bg);color:var(--text);font-family:"Inter",sans-serif;height:100vh;width:100vw;display:flex;align-items:center;justify-content:center;overflow:hidden;transition:background-color 0.3s ease,color 0.3s ease;}.diagram-container{width:min(100vw,calc(100vh * 1600 / 820));height:min(100vh,calc(100vw * 820 / 1600));position:relative;}.diagram-container img{width:100%;height:100%;display:block;}.nav{position:fixed;top:1.25rem;right:1.25rem;z-index:100;display:flex;align-items:center;gap:0.375rem;background:var(--pill-bg);border:1px solid var(--pill-border);border-radius:24px;padding:0.3rem 0.4rem;backdrop-filter:blur(12px);box-shadow:0 8px 32px rgba(0,0,0,0.18);}.nav a,.nav button{font-family:"IBM Plex Mono",monospace;font-size:0.78rem;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;padding:0.42rem 0.9rem;border-radius:18px;border:none;cursor:pointer;text-decoration:none;color:#94a3b8;background:transparent;transition:color 0.25s,background 0.25s;display:inline-flex;align-items:center;gap:0.3rem;}.nav a:hover,.nav button:hover{color:#22d3ee;}.nav a.active{background:linear-gradient(135deg,#6366f1,#22d3ee);color:#fff;}.nav .sep{width:1px;height:20px;background:var(--pill-border);}.nav .theme-btn{font-size:1.1rem;padding:0.42rem 0.6rem;color:var(--text);}@media (max-width:640px){.nav{top:0.75rem;right:0.75rem;}.nav a,.nav button{padding:0.35rem 0.65rem;font-size:0.72rem;}}</style></head><body><div class="nav"><button class="theme-btn" id="theme-btn" onclick="toggleTheme()" title="Toggle light/dark" > 🌙 </button><div class="sep"></div><a href="stratumiops-orchestrator.html" title="Stratum">← Stratum</a> ></div><div class="diagram-container"><img id="dark-svg" src="flow-stratum-build-pipeline.svg" alt="Stratum Build Pipeline Flow — Dark" /><img id="light-svg" src="w-flow-stratum-build-pipeline.svg" alt="Stratum Build Pipeline Flow — Light" style="display: none" /></div><script> const KEY = "stratumiops-theme";function applyTheme(t){const isDark = t !== "light";document.documentElement.classList.toggle("light",!isDark);document.getElementById("dark-svg").style.display = isDark ? "block" : "none";document.getElementById("light-svg").style.display = isDark ? "none" : "block";document.getElementById("theme-btn").textContent = isDark ? "☀️" : "🌙";localStorage.setItem(KEY,t);}function toggleTheme(){applyTheme(localStorage.getItem(KEY)=== "light" ? "dark" : "light");}applyTheme(localStorage.getItem(KEY)|| "dark");</script></body></html>

View File

@ -0,0 +1,469 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1170 830">
<defs>
<style>
.flow { animation: flow-a 3s linear infinite; }
.flow-fast { animation: flow-a 1.8s linear infinite; }
.flow-slow { animation: flow-a 5s linear infinite; }
.flow-vslow { animation: flow-a 7s linear infinite; }
@keyframes flow-a { from { stroke-dashoffset: 24; } to { stroke-dashoffset: 0; } }
.orbit { animation: orbit-a 8s linear infinite; }
@keyframes orbit-a { from { stroke-dashoffset: 0; } to { stroke-dashoffset: -68; } }
.glow-pulse { animation: glow-a 3s ease-in-out infinite; }
@keyframes glow-a { 0%,100% { opacity:.08; } 50% { opacity:.28; } }
.hb { animation: hb-a 3s ease-in-out infinite; }
.hb-d1 { animation-delay: .4s; }
.hb-d2 { animation-delay: .8s; }
.hb-d3 { animation-delay:1.2s; }
.hb-d4 { animation-delay:1.6s; }
.hb-d5 { animation-delay:2.0s; }
@keyframes hb-a { 0%,100% { opacity:.85; } 50% { opacity:1; } }
.particle-spin1 { animation: spin1 6s linear infinite; }
.particle-spin2 { animation: spin2 9s linear infinite; }
.particle-spin3 { animation: spin3 14s linear infinite; }
@keyframes spin1 { from { transform: rotate(0deg) translateX(120px); } to { transform: rotate(360deg) translateX(120px); } }
@keyframes spin2 { from { transform: rotate(120deg) translateX(120px); } to { transform: rotate(480deg) translateX(120px); } }
@keyframes spin3 { from { transform: rotate(240deg) translateX(120px); } to { transform: rotate(600deg) translateX(120px); } }
</style>
<radialGradient id="bg-grad" cx="50%" cy="42%" r="58%">
<stop offset="0%" stop-color="#F0F4F8"/>
<stop offset="100%" stop-color="#E2EAF4"/>
</radialGradient>
<linearGradient id="title-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#C7D2FE"/>
<stop offset="50%" stop-color="#E0E7FF"/>
<stop offset="100%" stop-color="#C7D2FE"/>
</linearGradient>
<linearGradient id="orch-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#EEF2FF"/>
<stop offset="100%" stop-color="#E0E7FF"/>
</linearGradient>
<linearGradient id="auth-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFFBEB"/>
<stop offset="100%" stop-color="#FEF9C3"/>
</linearGradient>
<linearGradient id="surreal-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F5F3FF"/>
<stop offset="100%" stop-color="#EDE9FE"/>
</linearGradient>
<linearGradient id="vault-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFFBEB"/>
<stop offset="100%" stop-color="#FEF3C7"/>
</linearGradient>
<linearGradient id="oci-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ECFEFF"/>
<stop offset="100%" stop-color="#CFFAFE"/>
</linearGradient>
<linearGradient id="forgejo-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFF7ED"/>
<stop offset="100%" stop-color="#FFEDD5"/>
</linearGradient>
<linearGradient id="exec-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F0FDF4"/>
<stop offset="100%" stop-color="#DCFCE7"/>
</linearGradient>
<linearGradient id="agent-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F5F3FF"/>
<stop offset="100%" stop-color="#EDE9FE"/>
</linearGradient>
<linearGradient id="ncl-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#6366F1" stop-opacity=".18"/>
<stop offset="50%" stop-color="#06B6D4" stop-opacity=".12"/>
<stop offset="100%" stop-color="#6366F1" stop-opacity=".18"/>
</linearGradient>
<linearGradient id="event-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFF7ED"/>
<stop offset="100%" stop-color="#FFEDD5"/>
</linearGradient>
<linearGradient id="mod-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#EEF2FF"/>
<stop offset="100%" stop-color="#E8EDFF"/>
</linearGradient>
<linearGradient id="mod-ag-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F5F3FF"/>
<stop offset="100%" stop-color="#EDE9FE"/>
</linearGradient>
<linearGradient id="mod-pc-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ECFEFF"/>
<stop offset="100%" stop-color="#CFFAFE"/>
</linearGradient>
<linearGradient id="mod-sr-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F0FDF4"/>
<stop offset="100%" stop-color="#DCFCE7"/>
</linearGradient>
<linearGradient id="mod-re-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFFBEB"/>
<stop offset="100%" stop-color="#FEF3C7"/>
</linearGradient>
<linearGradient id="orch-stripe" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#6366F1"/>
<stop offset="100%" stop-color="#06B6D4"/>
</linearGradient>
<filter id="shadow" x="-15%" y="-15%" width="130%" height="130%">
<feDropShadow dx="0" dy="3" stdDeviation="8" flood-color="#94A3B8" flood-opacity=".3"/>
</filter>
<filter id="glow-c" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<marker id="arr-cyan" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#0891B2"/></marker>
<marker id="arr-gold" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#D97706"/></marker>
<marker id="arr-green" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#059669"/></marker>
<marker id="arr-orange" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#EA6C00"/></marker>
<marker id="arr-purple" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#7C3AED"/></marker>
<marker id="arr-indigo" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#4F46E5"/></marker>
<marker id="arr-silver" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#64748B"/></marker>
<marker id="arr-dcyan" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#0891B2"/></marker>
<!-- ─── stratumiops-h logo defs ─── -->
<linearGradient id="hpLayerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#4F46E5"/>
</linearGradient>
<linearGradient id="hpProcessorGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#06B6D4"/>
</linearGradient>
<linearGradient id="hpFlowGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="0%;100%;0%" dur="2.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="100%;200%;100%" dur="2.5s" repeatCount="indefinite"/>
</linearGradient>
<linearGradient id="hpUnderlineGrad" x1="280" y1="195" x2="750" y2="195" gradientUnits="userSpaceOnUse">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
</linearGradient>
<linearGradient id="hpShimmer" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="40%" style="stop-color:#6366F1"/>
<stop offset="50%" style="stop-color:#22D3EE"/><stop offset="60%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="-100%;100%" dur="3s" repeatCount="indefinite" begin="2s"/>
<animate attributeName="x2" values="0%;200%" dur="3s" repeatCount="indefinite" begin="2s"/>
</linearGradient>
<filter id="hpProcessorGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="5" result="coloredBlur"><animate attributeName="stdDeviation" values="4;8;4" dur="1.5s" repeatCount="indefinite"/></feGaussianBlur>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="hpNodeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<clipPath id="hpTextReveal">
<rect x="280" y="80" width="0" height="150">
<animate attributeName="width" values="0;800" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
</clipPath>
<path id="hpPathIn1" d="M25 40 Q25 65 55 80 Q85 95 95 100" fill="none"/>
<path id="hpPathIn2" d="M215 40 Q215 65 185 80 Q155 95 145 100" fill="none"/>
<path id="hpPathOut1" d="M95 100 Q85 105 55 120 Q25 135 25 160" fill="none"/>
<path id="hpPathOut2" d="M145 100 Q155 105 185 120 Q215 135 215 160" fill="none"/>
</defs>
<!-- ─── BACKGROUND ─── -->
<rect width="1170" height="830" fill="url(#bg-grad)"/>
<g opacity=".06">
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M40 0L0 0 0 40" fill="none" stroke="#64748B" stroke-width=".5"/>
</pattern>
<rect width="1170" height="830" fill="url(#grid)"/>
</g>
<g opacity=".25">
<circle cx="90" cy="200" r=".8" fill="#94A3B8"/>
<circle cx="420" cy="88" r=".6" fill="#94A3B8"/>
<circle cx="980" cy="130" r="1" fill="#94A3B8"/>
<circle cx="55" cy="600" r=".5" fill="#94A3B8"/>
<circle cx="700" cy="800" r=".6" fill="#94A3B8"/>
</g>
<!-- ─── TITLE BAR ─── -->
<rect x="0" y="0" width="1170" height="60" fill="url(#title-grad)"/>
<line x1="0" y1="60" x2="1170" y2="60" stroke="#6366F1" stroke-width=".8" opacity=".5"/>
<text x="600" y="30" text-anchor="middle" fill="#1E40AF" font-family="'IBM Plex Mono',monospace" font-size="19" font-weight="700" letter-spacing="3" dominant-baseline="middle">STRATUM ORCHESTRATOR</text>
<text x="600" y="50" text-anchor="middle" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="9" letter-spacing="1.5">Event-Driven · Graph-Guided · Atomic Execution · Stateless · OCI-Native</text>
<!-- ─── EVENT SOURCES ROW ─── -->
<text x="400" y="82" text-anchor="middle" fill="#EA6C00" font-family="'IBM Plex Mono',monospace" font-size="8" letter-spacing="2" opacity=".9">EVENT SOURCES</text>
<rect x="55" y="90" width="128" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb"/>
<rect x="55" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="55" y="90" width="128" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="70" y="111" fill="#1E293B" font-family="Inter,sans-serif" font-size="11" font-weight="600">provisioning</text>
<text x="70" y="127" fill="#EA6C00" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".9">emits dev.crate.&gt;</text>
<text x="70" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">crate-modified · deploy</text>
<rect x="200" y="90" width="118" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d1"/>
<rect x="200" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="200" y="90" width="118" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="215" y="111" fill="#1E293B" font-family="Inter,sans-serif" font-size="11" font-weight="600">kogral</text>
<text x="215" y="127" fill="#EA6C00" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".9">emits dev.knowledge.&gt;</text>
<text x="215" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">node-updated · indexed</text>
<rect x="335" y="90" width="120" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d2"/>
<rect x="335" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="335" y="90" width="120" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="350" y="111" fill="#1E293B" font-family="Inter,sans-serif" font-size="11" font-weight="600">syntaxis</text>
<text x="350" y="127" fill="#EA6C00" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".9">emits dev.project.&gt;</text>
<text x="350" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">phase · task-completed</text>
<rect x="472" y="90" width="135" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d3"/>
<rect x="472" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="472" y="90" width="135" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="487" y="111" fill="#1E293B" font-family="Inter,sans-serif" font-size="11" font-weight="600">stratumiops</text>
<text x="487" y="127" fill="#EA6C00" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".9">emits dev.model.&gt;</text>
<text x="487" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">llm-call · embed-request</text>
<rect x="624" y="90" width="118" height="60" rx="6" fill="url(#event-grad)" filter="url(#shadow)" class="hb hb-d4"/>
<rect x="624" y="90" width="3" height="60" rx="1" fill="#F97316"/>
<rect x="624" y="90" width="118" height="60" rx="6" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="639" y="111" fill="#1E293B" font-family="Inter,sans-serif" font-size="11" font-weight="600">typedialog</text>
<text x="639" y="127" fill="#EA6C00" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".9">emits dev.form.&gt;</text>
<text x="639" y="141" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="7">submitted · validated</text>
<rect x="755" y="90" width="120" height="60" rx="6" fill="none" stroke="#94A3B8" stroke-width=".8" stroke-dasharray="4 3" opacity=".6"/>
<text x="815" y="127" text-anchor="middle" fill="#94A3B8" font-family="Inter,sans-serif" font-size="19">+ more ...</text>
<!-- ─── CONNECTIONS: PROJECTS → NATS ─── -->
<line x1="119" y1="150" x2="317" y2="262" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="259" y1="150" x2="337" y2="257" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="395" y1="150" x2="355" y2="256" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="539" y1="150" x2="374" y2="258" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<line x1="686" y1="150" x2="396" y2="263" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" opacity=".7"/>
<!-- ─── NATS ORBITAL RING ─── -->
<circle cx="355" cy="385" r="129" fill="none" stroke="#0891B2" stroke-width="14" opacity=".05" class="glow-pulse"/>
<circle cx="355" cy="385" r="123" fill="none" stroke="#0891B2" stroke-width="10" opacity=".08" class="glow-pulse" filter="url(#glow-c)"/>
<circle cx="355" cy="385" r="120" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-dasharray="10 7" class="orbit"/>
<circle cx="355" cy="385" r="66" fill="none" stroke="#6366F1" stroke-width=".5" opacity=".3"/>
<radialGradient id="nats-inner" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#0891B2" stop-opacity=".14"/>
<stop offset="100%" stop-color="#6366F1" stop-opacity=".06"/>
</radialGradient>
<circle cx="355" cy="385" r="66" fill="url(#nats-inner)"/>
<text x="355" y="379" text-anchor="middle" fill="#0E7490" font-family="'IBM Plex Mono',monospace" font-size="20" font-weight="700" filter="url(#glow-c)">NATS</text>
<text x="355" y="398" text-anchor="middle" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="9">JetStream</text>
<text x="344" y="235" text-anchor="middle" fill="#0E7490" font-family="'IBM Plex Mono',monospace" font-size="9" opacity=".8">dev.&gt;</text>
<g transform="translate(355,385)">
<circle r="4" fill="#0891B2" opacity=".9" class="particle-spin1"/>
<circle r="3.5" fill="#6366F1" opacity=".7" class="particle-spin2"/>
<circle r="3" fill="#F97316" opacity=".5" class="particle-spin3"/>
</g>
<!-- ─── NATS → ORCHESTRATOR ─── -->
<path d="M 475,385 L 495,385" stroke="#0891B2" stroke-width="3" stroke-dasharray="8 4" class="flow-fast" marker-end="url(#arr-cyan)" filter="url(#glow-c)"/>
<circle r="4" fill="#0891B2" opacity=".85">
<animateMotion dur="0.25s" repeatCount="indefinite" path="M 475,385 L 495,385"/>
</circle>
<circle r="3" fill="#6366F1" opacity=".7">
<animateMotion dur="0.25s" repeatCount="indefinite" begin="0.08s" path="M 475,385 L 495,385"/>
</circle>
<!-- NKey verify badge -->
<rect x="379" y="419" width="78" height="22" rx="3" fill="#FFFBEB" stroke="#D97706" stroke-width=".7" opacity=".9"/>
<text x="418" y="429" text-anchor="middle" fill="#D97706" font-family="'IBM Plex Mono',monospace" font-size="7">NKey verify</text>
<text x="418" y="439" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="6.5">ed25519 JWT</text>
<!-- ─── ORCHESTRATOR BOX ─── -->
<rect x="498" y="250" width="375" height="300" rx="10" fill="#CBD5E1" opacity=".25" transform="translate(3,5)"/>
<rect x="498" y="250" width="375" height="300" rx="10" fill="url(#orch-grad)" filter="url(#shadow)" class="hb"/>
<rect x="498" y="250" width="375" height="300" rx="10" fill="none" stroke="#6366F1" stroke-width=".8" opacity=".5" class="glow-pulse"/>
<rect x="498" y="250" width="4" height="300" rx="2" fill="url(#orch-stripe)"/>
<text x="685" y="273" text-anchor="middle" fill="#1E293B" font-family="Inter,sans-serif" font-size="14" font-weight="700">STRATUM ORCHESTRATOR</text>
<text x="685" y="290" text-anchor="middle" fill="#475569" font-family="Inter,sans-serif" font-size="9">Agnostic · Stateless · Graph-Guided</text>
<!-- Internal modules 2×2 -->
<!-- ActionGraph -->
<rect x="510" y="305" width="170" height="72" rx="6" fill="url(#mod-ag-grad)" stroke="#4F46E5" stroke-width=".7" opacity=".9"/>
<text x="522" y="325" fill="#4338CA" font-family="Inter,sans-serif" font-size="11" font-weight="600">◈ ActionGraph</text>
<text x="522" y="341" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">in-memory · Nickel nodes</text>
<text x="522" y="355" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">topo-sort · cycle-detect</text>
<!-- PipelineContext -->
<rect x="690" y="305" width="171" height="72" rx="6" fill="url(#mod-pc-grad)" stroke="#06B6D4" stroke-width=".7" opacity=".9"/>
<text x="702" y="325" fill="#4338CA" font-family="Inter,sans-serif" font-size="11" font-weight="600">⬡ PipelineCtx</text>
<text x="702" y="341" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">DB-first · typed caps</text>
<text x="702" y="355" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">schema-validated</text>
<!-- StageRunner -->
<rect x="510" y="389" width="170" height="130" rx="6" fill="url(#mod-sr-grad)" stroke="#059669" stroke-width=".7" opacity=".9"/>
<text x="522" y="407" fill="#4338CA" font-family="Inter,sans-serif" font-size="11" font-weight="600">▶ StageRunner</text>
<text x="522" y="423" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">JoinSet · parallel stages</text>
<text x="522" y="437" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">CancellationToken</text>
<text x="522" y="451" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">retry + backoff on failure</text>
<text x="522" y="465" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">saga compensate.nu</text>
<!-- RuleEngine -->
<rect x="690" y="389" width="171" height="130" rx="6" fill="url(#mod-re-grad)" stroke="#D97706" stroke-width=".7" opacity=".9"/>
<text x="702" y="407" fill="#4338CA" font-family="Inter,sans-serif" font-size="11" font-weight="600">⚙ RuleEngine</text>
<rect x="810" y="393" width="44" height="13" rx="3" fill="#FFFBEB" stroke="#D97706" stroke-width=".6"/>
<text x="832" y="402" text-anchor="middle" fill="#D97706" font-family="'IBM Plex Mono',monospace" font-size="7">Cedar</text>
<line x1="702" y1="415" x2="854" y2="415" stroke="#4F46E5" stroke-width=".4" opacity=".4"/>
<text x="702" y="428" fill="#D97706" font-family="'IBM Plex Mono',monospace" font-size="8" font-weight="600">◈ Cedar</text>
<text x="702" y="441" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">permit · forbid · conditions</text>
<text x="702" y="454" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">per-node authz policies</text>
<line x1="702" y1="463" x2="854" y2="463" stroke="#4F46E5" stroke-width=".4" opacity=".4"/>
<text x="702" y="476" fill="#D97706" font-family="'IBM Plex Mono',monospace" font-size="8" font-weight="600">◈ NKey</text>
<text x="702" y="489" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">ed25519 asymmetric keys</text>
<text x="702" y="502" fill="#475569" font-family="'IBM Plex Mono',monospace" font-size="8">JWT per-process · verify</text>
<text x="685" y="538" text-anchor="middle" fill="#DC2626" font-family="'IBM Plex Mono',monospace" font-size="8" opacity=".8">↺ Saga rollback on failure · compensate.nu in reverse</text>
<!-- ─── RIGHT PANEL: DATA STORES ─── -->
<path d="M 873,256 L 926,216" stroke="#7C3AED" stroke-width="1.5" stroke-dasharray="4 3" class="flow" marker-end="url(#arr-purple)" fill="none" opacity=".8"/>
<path d="M 873,350 L 926,310" stroke="#D97706" stroke-width="1.5" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-gold)" fill="none" opacity=".8"/>
<path d="M 873,444 L 926,404" stroke="#0891B2" stroke-width="1" stroke-dasharray="3 4" class="flow-slow" marker-end="url(#arr-dcyan)" fill="none" opacity=".7"/>
<!-- SurrealDB -->
<rect x="926" y="175" width="197" height="82" rx="8" fill="url(#surreal-grad)" filter="url(#shadow)" class="hb hb-d2"/>
<rect x="926" y="175" width="3" height="82" rx="1" fill="#7C3AED"/>
<rect x="926" y="175" width="197" height="82" rx="8" fill="none" stroke="#7C3AED" stroke-width=".8" opacity=".5"/>
<text x="941" y="198" fill="#7C3AED" font-family="Inter,sans-serif" font-size="12" font-weight="700">SurrealDB</text>
<text x="941" y="215" fill="#475569" font-family="Inter,sans-serif" font-size="9">Pipeline state · Step results</text>
<text x="941" y="231" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">orchestrator_state ns</text>
<text x="941" y="247" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">crash recovery</text>
<!-- SecretumVault -->
<rect x="926" y="269" width="197" height="82" rx="8" fill="url(#vault-grad)" filter="url(#shadow)" class="hb hb-d3"/>
<rect x="926" y="269" width="3" height="82" rx="1" fill="#D97706"/>
<rect x="926" y="269" width="197" height="82" rx="8" fill="none" stroke="#D97706" stroke-width=".8" opacity=".5"/>
<text x="941" y="292" fill="#D97706" font-family="Inter,sans-serif" font-size="12" font-weight="700">SecretumVault</text>
<text x="941" y="309" fill="#475569" font-family="Inter,sans-serif" font-size="9">Credentials · TTL leases</text>
<text x="941" y="325" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">vault:/secret/...</text>
<text x="941" y="341" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">never in NATS payload</text>
<!-- Zot OCI -->
<rect x="926" y="363" width="197" height="82" rx="8" fill="url(#oci-grad)" filter="url(#shadow)" class="hb hb-d4"/>
<rect x="926" y="363" width="3" height="82" rx="1" fill="#0891B2"/>
<rect x="926" y="363" width="197" height="82" rx="8" fill="none" stroke="#0891B2" stroke-width=".8" opacity=".5"/>
<text x="941" y="386" fill="#0891B2" font-family="Inter,sans-serif" font-size="12" font-weight="700">Zot OCI Registry</text>
<text x="941" y="403" fill="#475569" font-family="Inter,sans-serif" font-size="9">Node defs · Nickel libs</text>
<text x="941" y="419" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">oci://registry/nodes/</text>
<text x="941" y="435" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">content-addressed · signed</text>
<!-- ─── RIGHT PANEL: OPTIONAL SERVICES ─── -->
<text x="1042" y="533" text-anchor="middle" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="8" letter-spacing="2">OPTIONAL</text>
<path d="M 873,495 C 903,495 903,559 926,559" stroke="#94A3B8" stroke-width="1" stroke-dasharray="3 5" class="flow-vslow" marker-end="url(#arr-silver)" fill="none" opacity=".6"/>
<path d="M 873,510 C 905,510 905,653 926,653" stroke="#94A3B8" stroke-width="1" stroke-dasharray="3 5" class="flow-vslow" marker-end="url(#arr-silver)" fill="none" opacity=".6"/>
<path d="M 873,525 C 907,525 907,744 926,744" stroke="#94A3B8" stroke-width="1" stroke-dasharray="3 5" class="flow-vslow" marker-end="url(#arr-silver)" fill="none" opacity=".5"/>
<!-- Kogral -->
<rect x="926" y="518" width="197" height="82" rx="8" fill="#F8FAFC" stroke="#CBD5E1" stroke-width=".8" stroke-dasharray="5 4" opacity=".9" class="hb hb-d2"/>
<text x="941" y="541" fill="#475569" font-family="Inter,sans-serif" font-size="12" font-weight="600">Kogral</text>
<text x="941" y="558" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Knowledge graph</text>
<text x="941" y="574" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="8">node-updated triggers</text>
<rect x="1071" y="522" width="46" height="16" rx="3" fill="#E2E8F0"/>
<text x="1094" y="533" text-anchor="middle" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="7">optional</text>
<!-- Syntaxis -->
<rect x="926" y="612" width="197" height="82" rx="8" fill="#F8FAFC" stroke="#CBD5E1" stroke-width=".8" stroke-dasharray="5 4" opacity=".9" class="hb hb-d3"/>
<text x="941" y="635" fill="#475569" font-family="Inter,sans-serif" font-size="12" font-weight="600">Syntaxis</text>
<text x="941" y="652" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Project orchestration</text>
<text x="941" y="668" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="8">phase-transition events</text>
<rect x="1071" y="616" width="46" height="16" rx="3" fill="#E2E8F0"/>
<text x="1094" y="627" text-anchor="middle" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="7">optional</text>
<!-- TypeDialog -->
<rect x="926" y="706" width="197" height="75" rx="8" fill="#F8FAFC" stroke="#CBD5E1" stroke-width=".8" stroke-dasharray="5 4" opacity=".8" class="hb hb-d4"/>
<text x="941" y="729" fill="#475569" font-family="Inter,sans-serif" font-size="12" font-weight="600">TypeDialog</text>
<text x="941" y="746" fill="#64748B" font-family="Inter,sans-serif" font-size="9">Service config UI</text>
<text x="941" y="764" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="8">startup config NCL only</text>
<!-- ─── LEFT: Git repo ─── -->
<path d="M 498,545 C 120,650 50,350 180,337" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" fill="none" opacity=".6"/>
<path d="M 180,337 L 230,353" stroke="#F97316" stroke-width="1" stroke-dasharray="4 3" class="flow-slow" marker-end="url(#arr-orange)" fill="none" opacity=".7"/>
<rect x="30" y="295" width="150" height="85" rx="8" fill="url(#forgejo-grad)" filter="url(#shadow)" class="hb hb-d5"/>
<rect x="30" y="295" width="3" height="85" rx="1" fill="#F97316"/>
<rect x="30" y="295" width="150" height="85" rx="8" fill="none" stroke="#F97316" stroke-width=".8" opacity=".5"/>
<text x="45" y="318" fill="#EA6C00" font-family="Inter,sans-serif" font-size="12" font-weight="700">Git repo</text>
<text x="45" y="335" fill="#475569" font-family="Inter,sans-serif" font-size="9">Git events → NATS</text>
<text x="45" y="351" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">webhook → dev.crate.&gt;</text>
<text x="45" y="367" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">push · tag · pr</text>
<!-- ─── EXECUTION LAYER ─── -->
<path d="M 518,550 C 500,560 485,610 483,614" stroke="#059669" stroke-width="1.5" stroke-dasharray="5 3" class="flow" marker-end="url(#arr-green)" fill="none" opacity=".8"/>
<path d="M 773,550 C 755,560 717,610 714,616" stroke="#4F46E5" stroke-width="1.5" stroke-dasharray="5 3" class="flow" marker-end="url(#arr-indigo)" fill="none" opacity=".8"/>
<rect x="380" y="623" width="200" height="80" rx="8" fill="url(#exec-grad)" filter="url(#shadow)" class="hb"/>
<rect x="380" y="623" width="3" height="80" rx="1" fill="#059669"/>
<rect x="380" y="623" width="200" height="80" rx="8" fill="none" stroke="#059669" stroke-width=".8" opacity=".5"/>
<text x="395" y="644" fill="#059669" font-family="Inter,sans-serif" font-size="12" font-weight="700">Nu Executor</text>
<text x="395" y="660" fill="#475569" font-family="Inter,sans-serif" font-size="9">Atomic steps · Pure functions</text>
<text x="395" y="675" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">scripts/nu/*.nu</text>
<text x="395" y="689" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">stdout=output · exit-code=status</text>
<rect x="615" y="623" width="200" height="80" rx="8" fill="url(#agent-grad)" filter="url(#shadow)" class="hb hb-d1"/>
<rect x="615" y="623" width="3" height="80" rx="1" fill="#4F46E5"/>
<rect x="615" y="623" width="200" height="80" rx="8" fill="none" stroke="#4F46E5" stroke-width=".8" opacity=".5"/>
<text x="630" y="644" fill="#4F46E5" font-family="Inter,sans-serif" font-size="12" font-weight="700">AI Agent</text>
<text x="630" y="660" fill="#475569" font-family="Inter,sans-serif" font-size="9">stratum-llm · NATS protocol</text>
<text x="630" y="675" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">dev.agent.*.requested/responded</text>
<text x="630" y="689" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">oneshot correlation · timeout</text>
<!-- ─── NICKEL BASE LIBRARY ─── -->
<rect x="390" y="738" width="415" height="65" rx="8" fill="#F0F4FF" stroke="none"/>
<rect x="390" y="738" width="415" height="65" rx="8" fill="url(#ncl-grad)"/>
<rect x="390" y="738" width="415" height="65" rx="8" fill="none" stroke="#6366F1" stroke-width=".8" stroke-dasharray="6 4" opacity=".6"/>
<text x="597" y="761" text-anchor="middle" fill="#4338CA" font-family="Inter,sans-serif" font-size="12" font-weight="600">Nickel Base Library</text>
<text x="597" y="777" text-anchor="middle" fill="#475569" font-family="Inter,sans-serif" font-size="9">OCI-published · content-addressed · build-verified · typecheck-gated</text>
<text x="597" y="791" text-anchor="middle" fill="#64748B" font-family="'IBM Plex Mono',monospace" font-size="8">orchestrator-types.ncl · capability-schemas.ncl · defaults.ncl</text>
<!-- Zot OCI → Nickel -->
<path d="M 926,414 C 900,390 900,750 805,770" stroke="#0891B2" stroke-width="1" stroke-dasharray="3 3" class="flow-slow" marker-end="url(#arr-dcyan)" fill="none" opacity=".6"/>
<!-- ─── LOGO above LEGEND ─── -->
<g transform="translate(8,5) scale(0.2)">
<g transform="translate(20,50)">
<rect x="-220" y="18" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.5;0.8;0.5" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<rect x="-220" y="88" width="220" height="24" rx="5" fill="url(#hpLayerGrad)">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.15s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
<rect x="-220" y="158" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.3s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.8;0.5;0.8" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<path d="M35 42 Q35 65 70 85 Q95 100 95 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.5s"/></path>
<path d="M185 42 Q185 65 150 85 Q125 100 125 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.6s"/></path>
<path d="M95 100 Q95 100 70 115 Q35 135 35 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.7s"/></path>
<path d="M125 100 Q125 100 150 115 Q185 135 185 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.8s"/></path>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.5s"><mpath href="#hpPathIn1"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.8s"><mpath href="#hpPathIn2"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.1s"><mpath href="#hpPathOut1"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.1s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.4s"><mpath href="#hpPathOut2"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.4s"/></circle>
<circle cx="35" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.25s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="185" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.3s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="35" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.4s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle cx="185" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.45s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<rect x="85" y="75" width="50" height="50" rx="9" fill="url(#hpProcessorGrad)" filter="url(#hpProcessorGlow)" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.3s" fill="freeze" begin="0.45s"/></rect>
<rect x="97" y="87" width="26" height="26" rx="5" fill="#ffffff" opacity="0"><animate attributeName="opacity" values="0;0.95" dur="0.25s" fill="freeze" begin="0.55s"/></rect>
<rect x="101" y="96" width="4" height="8" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.7s"/><animate attributeName="height" values="8;14;6;10;8" dur="0.6s" repeatCount="indefinite" begin="1.2s"/><animate attributeName="y" values="96;93;97;95;96" dur="0.6s" repeatCount="indefinite" begin="1.2s"/></rect>
<rect x="108" y="93" width="4" height="14" rx="1" fill="#06B6D4" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.75s"/><animate attributeName="height" values="14;8;12;16;14" dur="0.55s" repeatCount="indefinite" begin="1.25s"/><animate attributeName="y" values="93;96;94;92;93" dur="0.55s" repeatCount="indefinite" begin="1.25s"/></rect>
<rect x="115" y="95" width="4" height="10" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.8s"/><animate attributeName="height" values="10;16;8;12;10" dur="0.5s" repeatCount="indefinite" begin="1.3s"/><animate attributeName="y" values="95;92;96;94;95" dur="0.5s" repeatCount="indefinite" begin="1.3s"/></rect>
<rect x="85" y="75" width="50" height="50" rx="9" fill="none" stroke="#22D3EE" stroke-width="1.5" opacity="0"><animate attributeName="x" values="85;70" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="y" values="75;60" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="width" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="height" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="rx" values="9;14" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="opacity" values="0.5;0" dur="2s" repeatCount="indefinite" begin="1.5s"/></rect>
</g>
<g clip-path="url(#hpTextReveal)">
<text x="280" y="175" font-family="'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-weight="700" font-size="72" fill="url(#hpShimmer)">Stratum<tspan fill="url(#hpProcessorGrad)">I</tspan>Ops</text>
</g>
<rect x="280" y="115" width="4" height="70" rx="2" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1;1;0;0" dur="0.8s" fill="freeze" begin="1s" keyTimes="0;0.1;0.5;0.51;1"/><animate attributeName="x" values="280;980" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></rect>
<line x1="280" y1="195" x2="280" y2="195" stroke="url(#hpUnderlineGrad)" stroke-width="3" stroke-linecap="round" opacity="0"><animate attributeName="opacity" values="0;0.7" dur="0.1s" fill="freeze" begin="1.4s"/><animate attributeName="x2" values="280;750" dur="0.8s" fill="freeze" begin="1.4s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></line>
</g>
<!-- ─── LEGEND ─── -->
<rect x="45" y="718" width="270" height="88" rx="6" fill="#F8FAFC" stroke="#CBD5E1" stroke-width=".8"/>
<text x="180" y="736" text-anchor="middle" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="8" letter-spacing="2">LEGEND</text>
<line x1="55" y1="750" x2="85" y2="750" stroke="#0891B2" stroke-width="2" stroke-dasharray="6 3"/>
<text x="91" y="754" fill="#475569" font-family="Inter,sans-serif" font-size="9">event flow (NATS)</text>
<line x1="55" y1="768" x2="85" y2="768" stroke="#D97706" stroke-width="2" stroke-dasharray="6 3"/>
<text x="91" y="772" fill="#475569" font-family="Inter,sans-serif" font-size="9">auth / credentials</text>
<line x1="55" y1="786" x2="85" y2="786" stroke="#059669" stroke-width="2" stroke-dasharray="6 3"/>
<text x="91" y="790" fill="#475569" font-family="Inter,sans-serif" font-size="9">execution</text>
<line x1="213" y1="750" x2="243" y2="750" stroke="#7C3AED" stroke-width="2" stroke-dasharray="6 3"/>
<text x="249" y="754" fill="#475569" font-family="Inter,sans-serif" font-size="9">state (DB)</text>
<line x1="213" y1="768" x2="243" y2="768" stroke="#0891B2" stroke-width="2" stroke-dasharray="6 3"/>
<text x="249" y="772" fill="#475569" font-family="Inter,sans-serif" font-size="9">OCI / registry</text>
<line x1="213" y1="786" x2="243" y2="786" stroke="#94A3B8" stroke-width="1.5" stroke-dasharray="3 5"/>
<text x="249" y="790" fill="#475569" font-family="Inter,sans-serif" font-size="9">optional</text>
<!-- ─── BRANDING ─── -->
<text x="1140" y="817" text-anchor="end" fill="#94A3B8" font-family="'IBM Plex Mono',monospace" font-size="9">stratumiops · v0.1</text>
</svg>

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -0,0 +1,450 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1600 820" width="1600" height="820">
<defs>
<filter id="glow-c" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="glow-g" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="glow-i" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<marker id="ac" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#22D3EE"/>
</marker>
<marker id="ai" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#6366F1"/>
</marker>
<marker id="ag" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#F59E0B"/>
</marker>
<marker id="agr" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#10B981"/>
</marker>
<marker id="ap" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#A78BFA"/>
</marker>
<marker id="ar" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#F43F5E"/>
</marker>
<marker id="aib" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#818CF8"/>
</marker>
<marker id="ail" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#A5B4FC"/>
</marker>
<marker id="ao" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#F97316"/>
</marker>
<!-- ─── stratumiops-h logo defs ─── -->
<linearGradient id="hpLayerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#4F46E5"/>
</linearGradient>
<linearGradient id="hpProcessorGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#06B6D4"/>
</linearGradient>
<linearGradient id="hpFlowGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="0%;100%;0%" dur="2.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="100%;200%;100%" dur="2.5s" repeatCount="indefinite"/>
</linearGradient>
<linearGradient id="hpUnderlineGrad" x1="280" y1="195" x2="750" y2="195" gradientUnits="userSpaceOnUse">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="50%" style="stop-color:#22D3EE"/><stop offset="100%" style="stop-color:#6366F1"/>
</linearGradient>
<linearGradient id="hpShimmer" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366F1"/><stop offset="40%" style="stop-color:#6366F1"/>
<stop offset="50%" style="stop-color:#22D3EE"/><stop offset="60%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#6366F1"/>
<animate attributeName="x1" values="-100%;100%" dur="3s" repeatCount="indefinite" begin="2s"/>
<animate attributeName="x2" values="0%;200%" dur="3s" repeatCount="indefinite" begin="2s"/>
</linearGradient>
<filter id="hpProcessorGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="5" result="coloredBlur"><animate attributeName="stdDeviation" values="4;8;4" dur="1.5s" repeatCount="indefinite"/></feGaussianBlur>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="hpNodeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge><feMergeNode in="coloredBlur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<clipPath id="hpTextReveal">
<rect x="280" y="80" width="0" height="150">
<animate attributeName="width" values="0;800" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
</clipPath>
<path id="hpPathIn1" d="M25 40 Q25 65 55 80 Q85 95 95 100" fill="none"/>
<path id="hpPathIn2" d="M215 40 Q215 65 185 80 Q155 95 145 100" fill="none"/>
<path id="hpPathOut1" d="M95 100 Q85 105 55 120 Q25 135 25 160" fill="none"/>
<path id="hpPathOut2" d="M145 100 Q155 105 185 120 Q215 135 215 160" fill="none"/>
</defs>
<style>
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.65} }
@keyframes nats-glow { 0%,100%{stroke-opacity:1;filter:url(#glow-c)} 50%{stroke-opacity:0.5;filter:none} }
@keyframes auth-glow { 0%,100%{stroke-opacity:1;filter:url(#glow-g)} 50%{stroke-opacity:0.4;filter:none} }
@keyframes flow-dash { to{stroke-dashoffset:-20} }
@keyframes flow-dash-slow { to{stroke-dashoffset:-20} }
.fd { animation: flow-dash 1.2s linear infinite; }
.fds { animation: flow-dash-slow 2s linear infinite; }
.nd { animation: nats-glow 1.8s ease-in-out infinite; }
.ah { animation: auth-glow 2s ease-in-out infinite; }
.p1 { animation: pulse 2.4s ease-in-out 0s infinite; }
.p2 { animation: pulse 2.4s ease-in-out 0.2s infinite; }
.p3 { animation: pulse 2.4s ease-in-out 0.4s infinite; }
.p4 { animation: pulse 2.4s ease-in-out 0.6s infinite; }
.p5 { animation: pulse 2.4s ease-in-out 0.8s infinite; }
.p6 { animation: pulse 2.4s ease-in-out 1.0s infinite; }
.p7 { animation: pulse 2.4s ease-in-out 1.2s infinite; }
.p8 { animation: pulse 2.4s ease-in-out 1.4s infinite; }
.p9 { animation: pulse 2.4s ease-in-out 1.6s infinite; }
.p10 { animation: pulse 2.4s ease-in-out 1.8s infinite; }
.p11 { animation: pulse 2.4s ease-in-out 0.3s infinite; }
.p12 { animation: pulse 2.4s ease-in-out 0.5s infinite; }
</style>
<!-- Background -->
<rect width="1600" height="820" fill="#F8FAFC"/>
<!-- TITLE BAR -->
<rect x="0" y="0" width="1600" height="2" fill="#22D3EE"/>
<rect x="0" y="0" width="1600" height="99" fill="#F1F5F9"/>
<text x="800" y="40" font-family="'IBM Plex Mono',monospace" font-size="19" fill="#0891B2" text-anchor="middle" font-weight="500" letter-spacing="2">STRATUM ORCHESTRATOR · BUILD PIPELINE FLOW</text>
<text x="800" y="66" font-family="'IBM Plex Mono',monospace" font-size="12" fill="#475569" text-anchor="middle">event: dev.crate.provisioning-cli.modified · trigger → ActionGraph → stages → state → emit</text>
<rect x="0" y="98" width="1600" height="2" fill="#E2E8F0"/>
<!-- LANE LABEL COLUMN SEPARATOR -->
<line x1="182" y1="100" x2="182" y2="742" stroke="#CBD5E1" stroke-width="2"/>
<!-- Lane 0: Developer / Forgejo -->
<rect x="0" y="100" width="6" height="80" fill="#F97316"/>
<rect x="6" y="100" width="176" height="80" fill="#F97316" fill-opacity="0.12"/>
<text x="22" y="131" font-family="'Inter',sans-serif" font-size="10" fill="#F97316" font-weight="600">Developer · Forgejo</text>
<text x="22" y="147" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">git push / webhook</text>
<!-- Lane 1: NATS JetStream -->
<rect x="0" y="180" width="6" height="80" fill="#22D3EE"/>
<rect x="6" y="180" width="176" height="80" fill="#22D3EE" fill-opacity="0.10"/>
<text x="22" y="211" font-family="'Inter',sans-serif" font-size="10" fill="#22D3EE" font-weight="600">NATS JetStream</text>
<text x="22" y="227" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">Event Bus</text>
<!-- Lane 2: Orchestrator Planning -->
<rect x="0" y="260" width="6" height="80" fill="#6366F1"/>
<rect x="6" y="260" width="176" height="80" fill="#6366F1" fill-opacity="0.10"/>
<text x="22" y="291" font-family="'Inter',sans-serif" font-size="10" fill="#6366F1" font-weight="600">Orchestrator</text>
<text x="22" y="307" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">Planning</text>
<!-- Lane 3: Auth -->
<rect x="0" y="340" width="6" height="80" fill="#F59E0B"/>
<rect x="6" y="340" width="176" height="80" fill="#F59E0B" fill-opacity="0.10"/>
<text x="22" y="371" font-family="'Inter',sans-serif" font-size="10" fill="#F59E0B" font-weight="600">Auth · NKeys</text>
<text x="22" y="387" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">+ Cedar</text>
<!-- Lane 4: Stage 0 Parallel -->
<rect x="0" y="420" width="6" height="80" fill="#10B981"/>
<rect x="6" y="420" width="176" height="80" fill="#10B981" fill-opacity="0.10"/>
<text x="22" y="451" font-family="'Inter',sans-serif" font-size="10" fill="#10B981" font-weight="600">Stage 0</text>
<text x="22" y="467" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">‖ Parallel</text>
<!-- Lane 5: Stage 1 -->
<rect x="0" y="500" width="6" height="80" fill="#818CF8"/>
<rect x="6" y="500" width="176" height="80" fill="#818CF8" fill-opacity="0.08"/>
<text x="22" y="531" font-family="'Inter',sans-serif" font-size="10" fill="#818CF8" font-weight="600">Stage 1</text>
<text x="22" y="547" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">→ Sequential</text>
<!-- Lane 6: Stage 2 -->
<rect x="0" y="580" width="6" height="80" fill="#A5B4FC"/>
<rect x="6" y="580" width="176" height="80" fill="#A5B4FC" fill-opacity="0.07"/>
<text x="22" y="611" font-family="'Inter',sans-serif" font-size="10" fill="#A5B4FC" font-weight="600">Stage 2</text>
<text x="22" y="627" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">→ Sequential</text>
<!-- Lane 7: SurrealDB -->
<rect x="0" y="660" width="6" height="80" fill="#A78BFA"/>
<rect x="6" y="660" width="176" height="80" fill="#A78BFA" fill-opacity="0.10"/>
<text x="22" y="691" font-family="'Inter',sans-serif" font-size="10" fill="#A78BFA" font-weight="600">SurrealDB</text>
<text x="22" y="707" font-family="'Inter',sans-serif" font-size="9" fill="#64748B">State</text>
<!-- LANE DIVIDERS (content area) -->
<line x1="182" y1="180" x2="1590" y2="180" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="260" x2="1590" y2="260" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="340" x2="1590" y2="340" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="420" x2="1590" y2="420" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="500" x2="1590" y2="500" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="580" x2="1590" y2="580" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="660" x2="1590" y2="660" stroke="#CBD5E1" stroke-width="1"/>
<line x1="182" y1="740" x2="1590" y2="740" stroke="#CBD5E1" stroke-width="1"/>
<!-- TIMELINE BASELINE (dashed, very subtle) -->
<path id="tl" d="M200,140 L1560,140" fill="none" stroke="#F97316" stroke-width="1" stroke-dasharray="5,4" opacity="0.2" class="fd"/>
<!-- ANIMATED PARTICLES along timeline -->
<circle r="5" fill="#22D3EE" opacity="0.85" filter="url(#glow-c)">
<animateMotion dur="6s" repeatCount="indefinite" begin="0s">
<mpath xlink:href="#tl"/>
</animateMotion>
</circle>
<circle r="4" fill="#6366F1" opacity="0.7">
<animateMotion dur="6s" repeatCount="indefinite" begin="2s">
<mpath xlink:href="#tl"/>
</animateMotion>
</circle>
<circle r="3.5" fill="#F59E0B" opacity="0.65">
<animateMotion dur="6s" repeatCount="indefinite" begin="4s">
<mpath xlink:href="#tl"/>
</animateMotion>
</circle>
<!-- ========== STEP 1: git push (Developer, x=220, yc=140) ========== -->
<g class="p1">
<circle cx="220" cy="112" r="9" fill="#F97316"/>
<text x="220" y="116" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">1</text>
<rect x="185" y="120" width="70" height="40" rx="5" fill="#F97316" fill-opacity="0.18" stroke="#F97316" stroke-width="1.5"/>
<text x="220" y="136" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F97316" text-anchor="middle" font-weight="500">git push</text>
<text x="220" y="150" font-family="'Inter',sans-serif" font-size="8" fill="#64748B" text-anchor="middle">code change</text>
<text x="220" y="170" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#334155" text-anchor="middle">commit: abc123f</text>
</g>
<!-- ========== STEP 2: webhook→NATS diamond (spans Developer/NATS border at y=180, x=330) ========== -->
<g class="nd p2">
<circle cx="330" cy="112" r="9" fill="#F97316"/>
<text x="330" y="116" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">2</text>
<polygon points="330,128 400,178 330,228 260,178" fill="#22D3EE" fill-opacity="0.12" stroke="#22D3EE" stroke-width="1.5"/>
<text x="330" y="163" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">dev.crate.</text>
<text x="330" y="178" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">provisioning-cli</text>
<text x="330" y="193" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">.modified</text>
<rect x="283" y="220" width="96" height="24" rx="3" fill="#F1F5F9" stroke="#CBD5E1" stroke-width="1"/>
<text x="330" y="231" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#334155" text-anchor="middle">&#123;"crate":"prvng-cli",</text>
<text x="330" y="241" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#334155" text-anchor="middle">"sha":"abc123f"&#125;</text>
</g>
<!-- ========== STEP 3: pull_batch(10) (Orchestrator, x=450, yc=300) ========== -->
<g class="p3">
<circle cx="450" cy="268" r="9" fill="#6366F1"/>
<text x="450" y="272" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">3</text>
<rect x="415" y="277" width="70" height="44" rx="5" fill="#6366F1" fill-opacity="0.18" stroke="#6366F1" stroke-width="1.5"/>
<text x="450" y="294" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#818CF8" text-anchor="middle" font-weight="500">pull_batch</text>
<text x="450" y="307" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#818CF8" text-anchor="middle">(10)</text>
<text x="450" y="332" font-family="'Inter',sans-serif" font-size="8" fill="#334155" text-anchor="middle">JetStream consumer</text>
</g>
<!-- ========== STEP 4: NKey verify (Auth hexagon, x=565, yc=382) ========== -->
<g class="ah p4">
<circle cx="565" cy="348" r="9" fill="#F59E0B"/>
<text x="565" y="352" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">4</text>
<polygon points="565,352 601,370 601,401 565,419 530,401 530,370" fill="#F59E0B" fill-opacity="0.12" stroke="#F59E0B" stroke-width="1.5"/>
<text x="565" y="381" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">NKey</text>
<text x="565" y="394" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">verify</text>
<text x="565" y="428" font-family="'Inter',sans-serif" font-size="8" fill="#334155" text-anchor="middle">publisher auth</text>
</g>
<!-- ========== STEP 5: ActionGraph query+topo-sort (Orchestrator, x=688, yc=299, wider) ========== -->
<g class="p5">
<circle cx="688" cy="268" r="9" fill="#6366F1"/>
<text x="688" y="272" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">5</text>
<rect x="644" y="277" width="88" height="46" rx="5" fill="#6366F1" fill-opacity="0.22" stroke="#6366F1" stroke-width="2" filter="url(#glow-i)"/>
<text x="688" y="293" font-family="'IBM Plex Mono',monospace" font-size="8.5" fill="#3730A3" text-anchor="middle" font-weight="600">ActionGraph</text>
<text x="688" y="306" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#4338CA" text-anchor="middle">query + topo-sort</text>
<!-- topo order annotation -->
<rect x="644" y="330" width="88" height="36" rx="3" fill="#F1F5F9" stroke="#CBD5E1" stroke-width="1"/>
<text x="688" y="341" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#334155" text-anchor="middle">[lint-crate, fmt-crate]</text>
<text x="688" y="351" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#334155" text-anchor="middle">→ build-crate</text>
<text x="688" y="361" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#334155" text-anchor="middle">→ install</text>
<!-- Stages bracket -->
<line x1="740" y1="277" x2="740" y2="502" stroke="#334155" stroke-width="1" stroke-dasharray="3,3"/>
<text x="754" y="396" font-family="'Inter',sans-serif" font-size="8" fill="#64748B" text-anchor="middle" transform="rotate(90,754,396)">Stages</text>
</g>
<!-- ========== STEP 6: Cedar authorize (Auth hexagon, x=800, yc=382) ========== -->
<g class="ah p6">
<circle cx="800" cy="348" r="9" fill="#F59E0B"/>
<text x="800" y="352" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">6</text>
<polygon points="800,352 836,370 836,401 800,419 765,401 765,370" fill="#F59E0B" fill-opacity="0.12" stroke="#F59E0B" stroke-width="1.5"/>
<text x="800" y="381" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">Cedar</text>
<text x="800" y="394" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#F59E0B" text-anchor="middle">authorize</text>
<text x="800" y="425" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#334155" text-anchor="middle">permit: trigger</text>
<text x="800" y="435" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#334155" text-anchor="middle">node:build-crate</text>
</g>
<!-- ========== STEP 7: Stage 0 PARALLEL wide box (Stage 0, x=876-1036, yc=460) ========== -->
<!-- parallel bracket above -->
<path d="M878,428 L878,423 L1038,423 L1038,428" fill="none" stroke="#10B981" stroke-width="1.2" stroke-dasharray="3,2"/>
<text x="958" y="420" font-family="'Inter',sans-serif" font-size="9" fill="#10B981" text-anchor="middle" font-weight="600">‖ parallel · JoinSet</text>
<g class="p7">
<circle cx="878" cy="430" r="9" fill="#10B981"/>
<text x="878" y="434" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">7</text>
<!-- wide rect -->
<rect x="878" y="437" width="160" height="44" rx="5" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1.5"/>
<!-- center divider -->
<line x1="958" y1="437" x2="958" y2="481" stroke="#10B981" stroke-width="1" stroke-dasharray="3,2" opacity="0.6"/>
<!-- LEFT: lint.nu -->
<text x="918" y="455" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#10B981" text-anchor="middle" font-weight="500">lint.nu</text>
<text x="918" y="469" font-family="'Inter',sans-serif" font-size="8" fill="#64748B" text-anchor="middle">(clippy)</text>
<!-- RIGHT: fmt.nu -->
<text x="998" y="455" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#10B981" text-anchor="middle" font-weight="500">fmt.nu</text>
<text x="998" y="469" font-family="'Inter',sans-serif" font-size="8" fill="#64748B" text-anchor="middle">(cargo fmt)</text>
</g>
<!-- join arc below -->
<path d="M878,481 L878,494 L1038,494 L1038,481" fill="none" stroke="#10B981" stroke-width="1" stroke-dasharray="3,2" opacity="0.5"/>
<text x="958" y="506" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#334155" text-anchor="middle">join_all → PipelineContext</text>
<!-- ========== STEP 8: deposit capabilities (Orchestrator, x=1075, yc=300) ========== -->
<g class="p8">
<circle cx="1075" cy="268" r="9" fill="#6366F1"/>
<text x="1075" y="272" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">8</text>
<rect x="1038" y="277" width="74" height="46" rx="5" fill="#6366F1" fill-opacity="0.15" stroke="#6366F1" stroke-width="1.5"/>
<text x="1075" y="293" font-family="'IBM Plex Mono',monospace" font-size="8.5" fill="#818CF8" text-anchor="middle" font-weight="500">deposit:</text>
<text x="1075" y="307" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#64748B" text-anchor="middle">linted-code</text>
<text x="1075" y="318" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#64748B" text-anchor="middle">formatted-code</text>
</g>
<!-- ========== STEP 9: Stage 1: build.nu (Stage 1, x=1190, yc=540) ========== -->
<g class="p9">
<circle cx="1190" cy="508" r="9" fill="#818CF8"/>
<text x="1190" y="512" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">9</text>
<rect x="1153" y="517" width="74" height="44" rx="5" fill="#818CF8" fill-opacity="0.15" stroke="#818CF8" stroke-width="1.5"/>
<text x="1190" y="533" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#818CF8" text-anchor="middle" font-weight="500">build.nu</text>
<text x="1190" y="546" font-family="'Inter',sans-serif" font-size="8" fill="#64748B" text-anchor="middle">(cargo build)</text>
<!-- Vault annotation -->
<rect x="1153" y="567" width="74" height="22" rx="3" fill="#F1F5F9" stroke="#F59E0B" stroke-width="1" stroke-dasharray="3,2"/>
<text x="1190" y="578" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#F59E0B" text-anchor="middle">&#x1F510; Vault cred</text>
<text x="1190" y="588" font-family="'IBM Plex Mono',monospace" font-size="6.5" fill="#F59E0B" text-anchor="middle">TTL=step timeout</text>
<text x="1190" y="603" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#334155" text-anchor="middle">deposit: built-artifact</text>
</g>
<!-- ========== STEP 10: Stage 2: install.nu (Stage 2, x=1310, yc=620) ========== -->
<g class="p10">
<circle cx="1310" cy="588" r="9" fill="#A5B4FC"/>
<text x="1310" y="592" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">10</text>
<rect x="1263" y="597" width="94" height="44" rx="5" fill="#A5B4FC" fill-opacity="0.12" stroke="#A5B4FC" stroke-width="1.5"/>
<text x="1310" y="613" font-family="'IBM Plex Mono',monospace" font-size="9" fill="#A5B4FC" text-anchor="middle" font-weight="500">install.nu</text>
<text x="1310" y="625" font-family="'Inter',sans-serif" font-size="8" fill="#64748B" text-anchor="middle">extracts: built-artifact</text>
<text x="1310" y="650" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#334155" text-anchor="middle">deposit: installed</text>
</g>
<!-- ========== STEP 11: pipeline_run → SurrealDB (SurrealDB, x=1420, yc=700) ========== -->
<g class="p11">
<circle cx="1420" cy="668" r="9" fill="#A78BFA"/>
<text x="1420" y="672" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">11</text>
<rect x="1373" y="677" width="94" height="44" rx="5" fill="#A78BFA" fill-opacity="0.15" stroke="#A78BFA" stroke-width="1.5"/>
<text x="1420" y="693" font-family="'IBM Plex Mono',monospace" font-size="8.5" fill="#A78BFA" text-anchor="middle" font-weight="500">pipeline_run</text>
<text x="1420" y="706" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#64748B" text-anchor="middle">status: success</text>
<text x="1420" y="727" font-family="'Inter',sans-serif" font-size="7.5" fill="#334155" text-anchor="middle">all steps persisted</text>
</g>
<!-- ========== STEP 12: emit result event (NATS diamond, x=1530, yc=220) ========== -->
<g class="nd p12">
<circle cx="1530" cy="178" r="9" fill="#22D3EE"/>
<text x="1530" y="182" font-family="'IBM Plex Mono',monospace" font-size="9" fill="white" text-anchor="middle" font-weight="700">12</text>
<polygon points="1530,185 1592,220 1530,255 1468,220" fill="#22D3EE" fill-opacity="0.12" stroke="#22D3EE" stroke-width="1.5"/>
<text x="1530" y="208" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">dev.crate.</text>
<text x="1530" y="221" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">provisioning-cli</text>
<text x="1530" y="234" font-family="'IBM Plex Mono',monospace" font-size="8" fill="#22D3EE" text-anchor="middle">.built</text>
<text x="1530" y="264" font-family="'Inter',sans-serif" font-size="7.5" fill="#334155" text-anchor="middle">duration_ms, status</text>
</g>
<!-- ========== FLOW ARROWS ========== -->
<!-- 1→2: git push triggers webhook (Developer lane →) -->
<line x1="255" y1="140" x2="292" y2="162" stroke="#F97316" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#ao)" class="fd"/>
<!-- 2→3: NATS event received (cross-lane: NATS down to Orchestrator) -->
<line x1="330" y1="216" x2="330" y2="270" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ai)"/>
<line x1="330" y1="299" x2="415" y2="299" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#ai)" class="fds"/>
<!-- 3→4: Orchestrator requests NKey verify (cross-lane down to Auth) -->
<line x1="487" y1="299" x2="525" y2="365" stroke="#F59E0B" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ag)"/>
<!-- 4→5: Auth ok, Orchestrator proceeds (cross-lane back up + forward) -->
<line x1="606" y1="385" x2="644" y2="299" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ai)"/>
<!-- 5→6: ActionGraph built, Cedar authorize -->
<line x1="732" y1="300" x2="760" y2="365" stroke="#F59E0B" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ag)"/>
<!-- 6→7: Auth permits, launch Stage 0 -->
<line x1="833" y1="398" x2="878" y2="445" stroke="#10B981" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#agr)"/>
<!-- 7→8: Stage 0 join → deposit to Orchestrator -->
<line x1="1038" y1="458" x2="1075" y2="323" stroke="#6366F1" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ai)"/>
<!-- 8→9: Deposit done → Stage 1 -->
<line x1="1075" y1="323" x2="1153" y2="517" stroke="#818CF8" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#aib)"/>
<!-- 9→10: Stage 1 done → Stage 2 -->
<line x1="1227" y1="540" x2="1263" y2="617" stroke="#A5B4FC" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ail)"/>
<!-- 10→11: Stage 2 done → SurrealDB ACK -->
<line x1="1310" y1="641" x2="1373" y2="693" stroke="#A78BFA" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#ap)"/>
<!-- 11→12: SurrealDB persisted → emit result NATS -->
<path d="M1420,677 L1420,220 L1474,220" fill="none" stroke="#22D3EE" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#ac)" class="fds"/>
<!-- Orchestrator ↔ SurrealDB (bidirectional state) -->
<path d="M1075,323 Q1075,700 1373,700" fill="none" stroke="#A78BFA" stroke-width="1" stroke-dasharray="3,3" opacity="0.4"/>
<!-- ========== ROLLBACK PATH ========== -->
<path d="M1153,574 L800,574" fill="none" stroke="#F43F5E" stroke-width="2" stroke-dasharray="7,4" marker-end="url(#ar)" opacity="0.85"/>
<rect x="850" y="553" width="210" height="17" rx="3" fill="#F1F5F9" fill-opacity="0.95"/>
<text x="955" y="565" font-family="'IBM Plex Mono',monospace" font-size="7.5" fill="#F43F5E" text-anchor="middle">rollback path: compensate.nu in reverse order</text>
<!-- ========== LOGO above LEGEND ========== -->
<g transform="translate(10,15) scale(0.2)">
<g transform="translate(20,50)">
<rect x="-220" y="18" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.5;0.8;0.5" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<rect x="-220" y="88" width="220" height="24" rx="5" fill="url(#hpLayerGrad)">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.15s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
</rect>
<rect x="-220" y="158" width="220" height="24" rx="5" fill="url(#hpLayerGrad)" opacity="0.7">
<animate attributeName="x" values="-220;0" dur="0.5s" fill="freeze" begin="0.3s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/>
<animate attributeName="opacity" values="0.8;0.5;0.8" dur="3s" repeatCount="indefinite" begin="1.5s"/>
</rect>
<path d="M35 42 Q35 65 70 85 Q95 100 95 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.5s"/></path>
<path d="M185 42 Q185 65 150 85 Q125 100 125 100" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.6s"/></path>
<path d="M95 100 Q95 100 70 115 Q35 135 35 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.7s"/></path>
<path d="M125 100 Q125 100 150 115 Q185 135 185 158" stroke="url(#hpFlowGrad)" stroke-width="3" fill="none" opacity="0" stroke-linecap="round"><animate attributeName="opacity" values="0;0.6" dur="0.3s" fill="freeze" begin="0.8s"/></path>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.5s"><mpath href="#hpPathIn1"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle r="4" fill="#22D3EE" filter="url(#hpNodeGlow)"><animateMotion dur="1.2s" repeatCount="indefinite" begin="1.8s"><mpath href="#hpPathIn2"/></animateMotion><animate attributeName="opacity" values="1;0.5;0.2" dur="1.2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.1s"><mpath href="#hpPathOut1"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.1s"/></circle>
<circle r="3" fill="#6366F1"><animateMotion dur="1.2s" repeatCount="indefinite" begin="2.4s"><mpath href="#hpPathOut2"/></animateMotion><animate attributeName="opacity" values="0.2;0.5;1" dur="1.2s" repeatCount="indefinite" begin="2.4s"/></circle>
<circle cx="35" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.25s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="185" cy="30" r="0" fill="#22D3EE" filter="url(#hpNodeGlow)"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.3s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.5s"/></circle>
<circle cx="35" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.4s"/><animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<circle cx="185" cy="170" r="0" fill="#6366F1"><animate attributeName="r" values="0;6" dur="0.2s" fill="freeze" begin="0.45s"/><animate attributeName="r" values="6;5;6" dur="2s" repeatCount="indefinite" begin="1.8s"/></circle>
<rect x="85" y="75" width="50" height="50" rx="9" fill="url(#hpProcessorGrad)" filter="url(#hpProcessorGlow)" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.3s" fill="freeze" begin="0.45s"/></rect>
<rect x="97" y="87" width="26" height="26" rx="5" fill="#ffffff" opacity="0"><animate attributeName="opacity" values="0;0.95" dur="0.25s" fill="freeze" begin="0.55s"/></rect>
<rect x="101" y="96" width="4" height="8" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.7s"/><animate attributeName="height" values="8;14;6;10;8" dur="0.6s" repeatCount="indefinite" begin="1.2s"/><animate attributeName="y" values="96;93;97;95;96" dur="0.6s" repeatCount="indefinite" begin="1.2s"/></rect>
<rect x="108" y="93" width="4" height="14" rx="1" fill="#06B6D4" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.75s"/><animate attributeName="height" values="14;8;12;16;14" dur="0.55s" repeatCount="indefinite" begin="1.25s"/><animate attributeName="y" values="93;96;94;92;93" dur="0.55s" repeatCount="indefinite" begin="1.25s"/></rect>
<rect x="115" y="95" width="4" height="10" rx="1" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1" dur="0.15s" fill="freeze" begin="0.8s"/><animate attributeName="height" values="10;16;8;12;10" dur="0.5s" repeatCount="indefinite" begin="1.3s"/><animate attributeName="y" values="95;92;96;94;95" dur="0.5s" repeatCount="indefinite" begin="1.3s"/></rect>
<rect x="85" y="75" width="50" height="50" rx="9" fill="none" stroke="#22D3EE" stroke-width="1.5" opacity="0"><animate attributeName="x" values="85;70" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="y" values="75;60" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="width" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="height" values="50;80" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="rx" values="9;14" dur="2s" repeatCount="indefinite" begin="1.5s"/><animate attributeName="opacity" values="0.5;0" dur="2s" repeatCount="indefinite" begin="1.5s"/></rect>
</g>
<g clip-path="url(#hpTextReveal)">
<text x="280" y="175" font-family="'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-weight="700" font-size="72" fill="url(#hpShimmer)">Stratum<tspan fill="url(#hpProcessorGrad)">I</tspan>Ops</text>
</g>
<rect x="280" y="115" width="4" height="70" rx="2" fill="#22D3EE" opacity="0"><animate attributeName="opacity" values="0;1;1;0;0" dur="0.8s" fill="freeze" begin="1s" keyTimes="0;0.1;0.5;0.51;1"/><animate attributeName="x" values="280;980" dur="1s" fill="freeze" begin="1s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></rect>
<line x1="280" y1="195" x2="280" y2="195" stroke="url(#hpUnderlineGrad)" stroke-width="3" stroke-linecap="round" opacity="0"><animate attributeName="opacity" values="0;0.7" dur="0.1s" fill="freeze" begin="1.4s"/><animate attributeName="x2" values="280;750" dur="0.8s" fill="freeze" begin="1.4s" calcMode="spline" keySplines="0.25 0.1 0.25 1"/></line>
</g>
<!-- ========== LEGEND (y=760-800) ========== -->
<text x="192" y="770" font-family="'Inter',sans-serif" font-size="9" fill="#334155" font-weight="600">Legend:</text>
<rect x="240" y="759" width="11" height="11" rx="2" fill="#22D3EE" fill-opacity="0.25" stroke="#22D3EE" stroke-width="1"/>
<text x="256" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#64748B">NATS events</text>
<rect x="335" y="759" width="11" height="11" rx="2" fill="#6366F1" fill-opacity="0.25" stroke="#6366F1" stroke-width="1"/>
<text x="351" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#64748B">Orchestrator</text>
<rect x="434" y="759" width="11" height="11" rx="2" fill="#F59E0B" fill-opacity="0.25" stroke="#F59E0B" stroke-width="1"/>
<text x="450" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#64748B">Auth (NKeys + Cedar)</text>
<rect x="578" y="759" width="11" height="11" rx="2" fill="#10B981" fill-opacity="0.25" stroke="#10B981" stroke-width="1"/>
<text x="594" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#64748B">Execution (Stage 0)</text>
<rect x="726" y="759" width="11" height="11" rx="2" fill="#A78BFA" fill-opacity="0.25" stroke="#A78BFA" stroke-width="1"/>
<text x="742" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#64748B">State (SurrealDB)</text>
<line x1="852" y1="765" x2="875" y2="765" stroke="#F43F5E" stroke-width="2" stroke-dasharray="5,3"/>
<text x="882" y="769" font-family="'Inter',sans-serif" font-size="8.5" fill="#64748B">Rollback path</text>
<text x="1588" y="775" font-family="'Inter',sans-serif" font-size="8" fill="#374151" text-anchor="end">StratumIOps · stratum-orchestrator</text>
<text x="1588" y="789" font-family="'IBM Plex Mono',monospace" font-size="7" fill="#374151" text-anchor="end">stratum-orchestrator · build pipeline</text>
</svg>

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -0,0 +1,6 @@
// Permit the orchestrator principal to execute any node.
permit(
principal == User::"orchestrator",
action == Action::"execute",
resource in ResourceGroup::"nodes"
);

View File

@ -0,0 +1,18 @@
{
surrealdb_url | String = "ws://localhost:8100",
nats_url | String = "nats://localhost:4222",
zot_url | String = "localhost:5000",
vault_url | String = "http://localhost:9094",
action_nodes_dir | String = "./action-nodes",
schemas_dir | String = "./schemas/capabilities",
cedar_policy_dir | String = "./config/cedar",
log_level | [| 'trace, 'debug, 'info, 'warn, 'error |] = 'info,
http_port | Number = 9088,
trusted_nkeys | Array String = [],
features = {
nkey_auth_required | Bool = false,
cedar_auth_required | Bool = true,
schema_validation | Bool = true,
agent_executor_enabled | Bool = false,
},
}

18
config/zot-config.json Normal file
View File

@ -0,0 +1,18 @@
{
"distSpecVersion": "1.1.0",
"storage": {
"rootDirectory": "/var/lib/registry"
},
"http": {
"address": "0.0.0.0",
"port": "5000"
},
"log": {
"level": "info"
},
"extensions": {
"search": {
"enable": true
}
}
}

View File

@ -0,0 +1,18 @@
[package]
name = "ncl-import-resolver"
version = "0.1.0"
edition.workspace = true
description = "Resolve OCI-hosted Nickel library imports to local filesystem cache"
license.workspace = true
[[bin]]
name = "ncl-import-resolver"
path = "src/main.rs"
[dependencies]
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
dirs = { workspace = true }

View File

@ -0,0 +1,131 @@
use std::{
path::{Path, PathBuf},
process::Command,
};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use tracing::info;
/// One entry in `resolver-manifest.json`.
#[derive(Debug, Serialize, Deserialize)]
struct ManifestEntry {
name: String,
registry: String,
tag: String,
/// Expected sha256 of the exported JSON (annotated on the OCI artifact)
expected_sha256: Option<String>,
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()))
.init();
let manifest_path = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("resolver-manifest.json"));
let manifest = load_manifest(&manifest_path)?;
let cache_base = cache_dir()?;
for entry in &manifest {
resolve_entry(entry, &cache_base)?;
}
info!("all Nickel imports resolved");
Ok(())
}
fn load_manifest(path: &Path) -> Result<Vec<ManifestEntry>> {
let content =
std::fs::read_to_string(path).with_context(|| format!("reading manifest '{}'", path.display()))?;
serde_json::from_str(&content).context("parsing resolver manifest JSON")
}
fn cache_dir() -> Result<PathBuf> {
let base = dirs::cache_dir()
.ok_or_else(|| anyhow!("could not determine cache directory"))?;
let dir = base.join("stratum").join("ncl");
std::fs::create_dir_all(&dir)
.with_context(|| format!("creating cache dir '{}'", dir.display()))?;
Ok(dir)
}
fn resolve_entry(entry: &ManifestEntry, cache_base: &Path) -> Result<()> {
let image_ref = format!("{}/{}:{}", entry.registry, entry.name, entry.tag);
let dest_dir = cache_base.join(&entry.name).join(&entry.tag);
if dest_dir.exists() {
info!("cache hit: {} → {}", image_ref, dest_dir.display());
symlink_to_nickel_dir(&entry.name, &dest_dir)?;
return Ok(());
}
info!("pulling: {image_ref}");
std::fs::create_dir_all(&dest_dir)?;
let export_file = dest_dir.join(format!("{}.json", entry.name));
let output = Command::new("oras")
.arg("pull")
.arg("--output")
.arg(&dest_dir)
.arg(&image_ref)
.output()
.with_context(|| format!("running oras pull for '{image_ref}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("oras pull failed for '{image_ref}': {stderr}"));
}
// Verify sha256 if expected
if let Some(expected) = &entry.expected_sha256 {
let actual = sha256_file(&export_file)?;
if actual != *expected {
std::fs::remove_dir_all(&dest_dir)?;
return Err(anyhow!(
"sha256 mismatch for '{image_ref}': expected {expected}, got {actual}"
));
}
info!("sha256 verified: {actual}");
}
symlink_to_nickel_dir(&entry.name, &dest_dir)?;
Ok(())
}
fn symlink_to_nickel_dir(name: &str, cache_dir: &Path) -> Result<()> {
let link = PathBuf::from("nickel").join(name);
if link.exists() || link.is_symlink() {
std::fs::remove_file(&link)
.with_context(|| format!("removing existing symlink '{}'", link.display()))?;
}
std::os::unix::fs::symlink(cache_dir, &link)
.with_context(|| format!("creating symlink '{}' → '{}'", link.display(), cache_dir.display()))?;
info!("symlinked: {} → {}", link.display(), cache_dir.display());
Ok(())
}
fn sha256_file(path: &Path) -> Result<String> {
let output = Command::new("sha256sum")
.arg(path)
.output()
.with_context(|| format!("running sha256sum on '{}'", path.display()))?;
if !output.status.success() {
return Err(anyhow!("sha256sum failed on '{}'", path.display()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.split_whitespace()
.next()
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("unexpected sha256sum output: {stdout}"))
}

View File

@ -0,0 +1,21 @@
[package]
name = "platform-nats"
version = "0.1.0"
edition.workspace = true
description = "NATS JetStream client with NKey authentication for stratum services"
license.workspace = true
[dependencies]
async-nats = { workspace = true }
nkeys = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
bytes = "1.9"
futures = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }

View File

@ -0,0 +1,112 @@
use std::collections::HashSet;
use anyhow::{anyhow, Result};
use bytes::Bytes;
pub struct NKeyAuth {
trusted_public_keys: HashSet<String>,
}
impl NKeyAuth {
pub fn new(trusted_public_keys: Vec<String>) -> Self {
Self {
trusted_public_keys: trusted_public_keys.into_iter().collect(),
}
}
/// Verify an incoming NATS message.
///
/// Expects:
/// - Header `Nats-Nkey`: ed25519 public key (base32/nkeys encoding)
/// - Header `Nats-Signature`: base64url signature of the message payload
///
/// Returns `Ok(())` if the message is trusted and the signature is valid.
/// Returns `Err` if missing, untrusted, or signature invalid.
pub fn verify_message(
&self,
headers: Option<&async_nats::header::HeaderMap>,
payload: &Bytes,
) -> Result<()> {
let headers =
headers.ok_or_else(|| anyhow!("message has no headers — Nats-Nkey required"))?;
let nkey_header = headers
.get("Nats-Nkey")
.ok_or_else(|| anyhow!("missing Nats-Nkey header"))?;
let nkey_str = nkey_header.as_str().trim();
if !self.trusted_public_keys.contains(nkey_str) {
return Err(anyhow!("untrusted publisher nkey: {nkey_str}"));
}
let sig_header = headers
.get("Nats-Signature")
.ok_or_else(|| anyhow!("missing Nats-Signature header"))?;
let sig_b64 = sig_header.as_str().trim();
let sig_bytes = base64_url_decode(sig_b64)
.map_err(|e| anyhow!("invalid Nats-Signature base64: {e}"))?;
let kp = nkeys::KeyPair::from_public_key(nkey_str)
.map_err(|e| anyhow!("invalid nkey public key: {e}"))?;
kp.verify(payload, &sig_bytes)
.map_err(|e| anyhow!("signature verification failed: {e}"))?;
Ok(())
}
pub fn is_auth_required(&self) -> bool {
!self.trusted_public_keys.is_empty()
}
}
fn base64_url_decode(input: &str) -> Result<Vec<u8>> {
let standard: String = input
.chars()
.map(|c| match c {
'-' => '+',
'_' => '/',
c => c,
})
.collect();
// Pad to multiple of 4
let pad = (4 - standard.len() % 4) % 4;
let padded = format!("{}{}", standard, "=".repeat(pad));
(0..padded.len())
.step_by(4)
.try_fold(Vec::new(), |mut acc, i| {
let chunk = &padded[i..(i + 4).min(padded.len())];
let decoded = base64_chunk(chunk)?;
acc.extend_from_slice(&decoded);
Ok::<Vec<u8>, anyhow::Error>(acc)
})
}
fn base64_chunk(chunk: &str) -> Result<Vec<u8>> {
// Simple base64 decode for a 4-char chunk
let b64_chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut val: u32 = 0;
let mut valid = 0u32;
for c in chunk.bytes() {
if c == b'=' {
break;
}
let pos = b64_chars
.iter()
.position(|&b| b == c)
.ok_or_else(|| anyhow!("invalid base64 character: {c}"))?;
val = (val << 6) | pos as u32;
valid += 6;
}
let mut result = Vec::new();
while valid >= 8 {
valid -= 8;
result.push(((val >> valid) & 0xFF) as u8);
}
Ok(result)
}

View File

@ -0,0 +1,179 @@
use anyhow::{anyhow, Context, Result};
use async_nats::{
jetstream::{
self, consumer::pull::Config as PullConfig, stream::Config as StreamConfig,
Message as JsMessage,
},
Client,
};
use bytes::Bytes;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::auth::NKeyAuth;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NatsConfig {
pub url: String,
/// ed25519 NKey seed for signing published messages (optional)
pub nkey_seed: Option<String>,
pub stream_name: String,
pub consumer_name: String,
pub subjects: Vec<String>,
/// If true, reject messages that lack valid NKey signatures
pub require_signed_messages: bool,
pub trusted_nkeys: Vec<String>,
}
pub struct EventStream {
client: Client,
consumer: jetstream::consumer::Consumer<PullConfig>,
auth: NKeyAuth,
signing_keypair: Option<nkeys::KeyPair>,
}
impl EventStream {
pub async fn connect(cfg: &NatsConfig) -> Result<Self> {
let client = async_nats::connect(&cfg.url)
.await
.with_context(|| format!("connecting to NATS at {}", cfg.url))?;
let js = jetstream::new(client.clone());
// Ensure stream exists with the configured subjects
js.get_or_create_stream(StreamConfig {
name: cfg.stream_name.clone(),
subjects: cfg.subjects.clone(),
..Default::default()
})
.await
.with_context(|| format!("creating/getting stream '{}'", cfg.stream_name))?;
let stream = js
.get_stream(&cfg.stream_name)
.await
.with_context(|| format!("getting stream '{}'", cfg.stream_name))?;
let consumer = stream
.get_or_create_consumer(
&cfg.consumer_name,
PullConfig {
durable_name: Some(cfg.consumer_name.clone()),
..Default::default()
},
)
.await
.with_context(|| format!("creating/getting consumer '{}'", cfg.consumer_name))?;
let auth = NKeyAuth::new(cfg.trusted_nkeys.clone());
let signing_keypair = cfg
.nkey_seed
.as_deref()
.map(|seed| nkeys::KeyPair::from_seed(seed).context("parsing NKey seed"))
.transpose()?;
Ok(Self {
client,
consumer,
auth,
signing_keypair,
})
}
/// Pull up to `max_msgs` messages. Returns (subject, payload, Message) tuples.
/// Messages that fail auth verification are nack'd and excluded from the result.
pub async fn pull_batch(&self, max_msgs: usize) -> Result<Vec<(String, Bytes, JsMessage)>> {
let mut messages = self
.consumer
.fetch()
.max_messages(max_msgs)
.messages()
.await
.context("pulling message batch")?;
let mut result = Vec::new();
while let Some(msg) = messages.next().await {
let msg: JsMessage = msg.map_err(|e| anyhow!("receiving message from stream: {e}"))?;
let subject = msg.subject.to_string();
let payload = msg.payload.clone();
if self.auth.is_auth_required() {
if let Err(e) = self.auth.verify_message(msg.headers.as_ref(), &payload) {
warn!("dropping message on '{subject}': {e}");
// Ack to remove from queue — do not block the stream with bad messages
let _ = msg.ack().await;
continue;
}
}
debug!("accepted message on '{subject}' ({} bytes)", payload.len());
result.push((subject, payload, msg));
}
Ok(result)
}
/// Publish a message, signing it with the configured NKey if available.
pub async fn publish(&self, subject: &str, payload: Bytes) -> Result<()> {
let mut headers = async_nats::header::HeaderMap::new();
if let Some(kp) = &self.signing_keypair {
let pub_key = kp.public_key();
let signature = kp
.sign(&payload)
.map_err(|e| anyhow!("signing message: {e}"))?;
let sig_b64 = base64_url_encode(&signature);
headers.insert("Nats-Nkey", pub_key.as_str());
headers.insert("Nats-Signature", sig_b64.as_str());
}
self.client
.publish_with_headers(subject.to_owned(), headers, payload)
.await
.with_context(|| format!("publishing to '{subject}'"))?;
Ok(())
}
pub fn client(&self) -> &Client {
&self.client
}
}
fn base64_url_encode(data: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b0 = chunk[0] as usize;
let b1 = chunk.get(1).copied().unwrap_or(0) as usize;
let b2 = chunk.get(2).copied().unwrap_or(0) as usize;
let combined = (b0 << 16) | (b1 << 8) | b2;
out.push(CHARS[(combined >> 18) & 0x3F] as char);
out.push(CHARS[(combined >> 12) & 0x3F] as char);
out.push(if chunk.len() > 1 {
CHARS[(combined >> 6) & 0x3F] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
CHARS[combined & 0x3F] as char
} else {
'='
});
}
// Convert to URL-safe base64
out.chars()
.map(|c| match c {
'+' => '-',
'/' => '_',
c => c,
})
.collect()
}

View File

@ -0,0 +1,5 @@
pub mod auth;
pub mod client;
pub use auth::NKeyAuth;
pub use client::{EventStream, NatsConfig};

View File

@ -0,0 +1,148 @@
/// Integration test: NKey authentication sign/verify round-trip.
///
/// Tests the full flow: key generation → signing → header injection → verification.
use async_nats::header::HeaderMap;
use bytes::Bytes;
use platform_nats::NKeyAuth;
/// Produce a `HeaderMap` with NKey authentication headers for the given payload.
fn signed_headers(seed: &str, payload: &[u8]) -> HeaderMap {
let kp = nkeys::KeyPair::from_seed(seed).unwrap();
let sig = kp.sign(payload).unwrap();
let sig_b64 = b64url_encode(&sig);
let pub_key = kp.public_key();
let mut headers = HeaderMap::new();
headers.insert("Nats-Nkey", pub_key.as_str());
headers.insert("Nats-Signature", sig_b64.as_str());
headers
}
/// Base64url encode (no padding, URL-safe alphabet: `-` and `_`).
///
/// Packs input into 3-byte chunks, encodes each as four 6-bit Base64 characters.
/// Partial trailing chunks emit 2 or 3 characters (no `=` padding).
fn b64url_encode(data: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
out.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
if chunk.len() > 1 {
out.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
}
if chunk.len() > 2 {
out.push(CHARS[(n & 0x3F) as usize] as char);
}
}
out
}
/// Generate a fresh user NKey seed for testing.
fn fresh_seed() -> String {
let kp = nkeys::KeyPair::new_user();
kp.seed().unwrap()
}
#[test]
fn test_trusted_key_signature_accepted() {
let seed = fresh_seed();
let kp = nkeys::KeyPair::from_seed(&seed).unwrap();
let pub_key = kp.public_key();
let auth = NKeyAuth::new(vec![pub_key]);
let payload = Bytes::from_static(b"hello stratum");
let headers = signed_headers(&seed, &payload);
auth.verify_message(Some(&headers), &payload).unwrap();
}
#[test]
fn test_untrusted_key_rejected() {
let trusted_seed = fresh_seed();
let trusted_kp = nkeys::KeyPair::from_seed(&trusted_seed).unwrap();
let auth = NKeyAuth::new(vec![trusted_kp.public_key()]);
// Sign with a different (untrusted) key
let attacker_seed = fresh_seed();
let payload = Bytes::from_static(b"malicious payload");
let headers = signed_headers(&attacker_seed, &payload);
let err = auth.verify_message(Some(&headers), &payload).unwrap_err();
assert!(
err.to_string().contains("untrusted publisher"),
"expected untrusted error, got: {err}"
);
}
#[test]
fn test_tampered_payload_rejected() {
let seed = fresh_seed();
let kp = nkeys::KeyPair::from_seed(&seed).unwrap();
let pub_key = kp.public_key();
let auth = NKeyAuth::new(vec![pub_key]);
let original_payload = Bytes::from_static(b"original payload");
let headers = signed_headers(&seed, &original_payload);
// Signature was for `original_payload`, but we verify against tampered one
let tampered = Bytes::from_static(b"tampered payload!!!");
let err = auth
.verify_message(Some(&headers), &tampered)
.unwrap_err();
assert!(
err.to_string().contains("signature verification failed")
|| err.to_string().contains("untrusted"),
"expected signature error, got: {err}"
);
}
#[test]
fn test_missing_headers_rejected() {
let auth = NKeyAuth::new(vec!["some-key".to_string()]);
let payload = Bytes::from_static(b"payload");
let err = auth.verify_message(None, &payload).unwrap_err();
assert!(
err.to_string().contains("no headers"),
"expected no-headers error, got: {err}"
);
}
#[test]
fn test_missing_nkey_header_rejected() {
let auth = NKeyAuth::new(vec!["some-key".to_string()]);
let payload = Bytes::from_static(b"payload");
let headers = HeaderMap::new(); // empty — no Nats-Nkey
let err = auth
.verify_message(Some(&headers), &payload)
.unwrap_err();
assert!(
err.to_string().contains("missing Nats-Nkey"),
"expected missing-header error, got: {err}"
);
}
#[test]
fn test_auth_not_required_when_no_trusted_keys() {
let auth = NKeyAuth::new(vec![]); // no trusted keys = auth not required
assert!(
!auth.is_auth_required(),
"empty trusted set must mean auth is not required"
);
}
#[test]
fn test_auth_required_when_trusted_keys_present() {
let kp = nkeys::KeyPair::new_user();
let auth = NKeyAuth::new(vec![kp.public_key()]);
assert!(
auth.is_auth_required(),
"non-empty trusted set must mean auth is required"
);
}

View File

@ -22,8 +22,6 @@ humantime-serde = { workspace = true }
# Caching
moka = { workspace = true }
# Persistent cache (optional)
sled = { workspace = true, optional = true }
# Local embeddings
fastembed = { workspace = true, optional = true }
@ -66,12 +64,12 @@ all-providers = [
# Cache backends
memory-cache = []
persistent-cache = ["sled"]
persistent-cache = ["surrealdb", "surrealdb/kv-rocksdb"]
all-cache = ["memory-cache", "persistent-cache"]
# Vector storage backends
lancedb-store = ["lancedb", "arrow"]
surrealdb-store = ["surrealdb"]
surrealdb-store = ["surrealdb", "surrealdb/kv-rocksdb"]
all-stores = ["lancedb-store", "surrealdb-store"]
# Observability

View File

@ -1,96 +1,122 @@
#[cfg(feature = "persistent-cache")]
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use std::path::Path;
#[cfg(feature = "persistent-cache")]
use async_trait::async_trait;
#[cfg(feature = "persistent-cache")]
use sled::Db;
use surrealdb::{
engine::local::{Db, Mem, RocksDb},
Surreal,
};
#[cfg(feature = "persistent-cache")]
use crate::{cache::EmbeddingCache, error::EmbeddingError, traits::Embedding};
pub struct PersistentCache {
db: Db,
db: Surreal<Db>,
size_hint: Arc<AtomicUsize>,
}
impl PersistentCache {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, EmbeddingError> {
let db = sled::open(path)
.map_err(|e| EmbeddingError::CacheError(format!("Failed to open sled db: {}", e)))?;
Ok(Self { db })
}
pub fn in_memory() -> Result<Self, EmbeddingError> {
let db = sled::Config::new().temporary(true).open().map_err(|e| {
EmbeddingError::CacheError(format!("Failed to create in-memory sled db: {}", e))
/// Persistent RocksDB-backed cache. Survives restarts.
pub async fn new(path: impl AsRef<Path>) -> Result<Self, EmbeddingError> {
let db = Surreal::new::<RocksDb>(path.as_ref()).await.map_err(|e| {
EmbeddingError::CacheError(format!("RocksDB open failed: {e}"))
})?;
Ok(Self { db })
}
fn serialize_embedding(embedding: &Embedding) -> Result<Vec<u8>, EmbeddingError> {
serde_json::to_vec(embedding)
.map_err(|e| EmbeddingError::SerializationError(format!("Embedding serialize: {}", e)))
}
fn deserialize_embedding(data: &[u8]) -> Result<Embedding, EmbeddingError> {
serde_json::from_slice(data).map_err(|e| {
EmbeddingError::SerializationError(format!("Embedding deserialize: {}", e))
db.use_ns("stratum")
.use_db("embeddings")
.await
.map_err(|e| EmbeddingError::CacheError(format!("Namespace select failed: {e}")))?;
let count = Self::db_count(&db).await?;
Ok(Self {
db,
size_hint: Arc::new(AtomicUsize::new(count)),
})
}
/// In-memory instance for tests and ephemeral use (kv-mem).
pub async fn in_memory() -> Result<Self, EmbeddingError> {
let db = Surreal::new::<Mem>(()).await.map_err(|e| {
EmbeddingError::CacheError(format!("In-memory SurrealDB init failed: {e}"))
})?;
db.use_ns("stratum")
.use_db("embeddings")
.await
.map_err(|e| EmbeddingError::CacheError(format!("Namespace select failed: {e}")))?;
Ok(Self {
db,
size_hint: Arc::new(AtomicUsize::new(0)),
})
}
async fn db_count(db: &Surreal<Db>) -> Result<usize, EmbeddingError> {
let rows: Vec<serde_json::Value> = db
.query("SELECT count() FROM embedding_cache_v1 GROUP ALL")
.await
.map_err(|e| EmbeddingError::CacheError(format!("Count query failed: {e}")))?
.take(0)
.map_err(|e| EmbeddingError::CacheError(format!("Count result extraction failed: {e}")))?;
Ok(rows
.into_iter()
.next()
.and_then(|v| v.get("count").and_then(|c| c.as_u64()))
.unwrap_or(0) as usize)
}
}
#[async_trait]
impl EmbeddingCache for PersistentCache {
async fn get(&self, key: &str) -> Option<Embedding> {
self.db
.get(key)
.ok()
.flatten()
.and_then(|bytes| Self::deserialize_embedding(&bytes).ok())
let raw: Option<serde_json::Value> = self
.db
.select(("embedding_cache_v1", key))
.await
.ok()?;
raw?.get("vector")
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
async fn insert(&self, key: &str, embedding: Embedding) {
if let Ok(bytes) = Self::serialize_embedding(&embedding) {
let _ = self.db.insert(key, bytes);
let _ = self.db.flush();
let payload = serde_json::json!({ "vector": embedding });
let result: Result<Option<serde_json::Value>, _> = self
.db
.upsert(("embedding_cache_v1", key))
.content(payload)
.await;
if result.is_ok() {
self.size_hint.fetch_add(1, Ordering::Relaxed);
}
}
async fn get_batch(&self, keys: &[String]) -> Vec<Option<Embedding>> {
keys.iter()
.map(|key| {
self.db
.get(key)
.ok()
.flatten()
.and_then(|bytes| Self::deserialize_embedding(&bytes).ok())
})
.collect()
let mut out = Vec::with_capacity(keys.len());
for key in keys {
out.push(self.get(key.as_str()).await);
}
out
}
async fn insert_batch(&self, items: Vec<(String, Embedding)>) {
for (key, embedding) in items {
if let Ok(bytes) = Self::serialize_embedding(&embedding) {
let _ = self.db.insert(key, bytes);
}
self.insert(key.as_str(), embedding).await;
}
let _ = self.db.flush();
}
async fn invalidate(&self, key: &str) {
let _ = self.db.remove(key);
let _ = self.db.flush();
let result: Result<Option<serde_json::Value>, _> =
self.db.delete(("embedding_cache_v1", key)).await;
if result.map(|opt| opt.is_some()).unwrap_or(false) {
self.size_hint.fetch_sub(1, Ordering::Relaxed);
}
}
async fn clear(&self) {
let _ = self.db.clear();
let _ = self.db.flush();
let _: Result<Vec<serde_json::Value>, _> = self.db.delete("embedding_cache_v1").await;
self.size_hint.store(0, Ordering::Relaxed);
}
fn size(&self) -> usize {
self.db.len()
self.size_hint.load(Ordering::Relaxed)
}
}
@ -100,7 +126,7 @@ mod tests {
#[tokio::test]
async fn test_persistent_cache_in_memory() {
let cache = PersistentCache::in_memory().expect("Failed to create cache");
let cache = PersistentCache::in_memory().await.expect("Failed to create cache");
let embedding = vec![1.0, 2.0, 3.0];
cache.insert("test_key", embedding.clone()).await;
@ -111,13 +137,12 @@ mod tests {
#[tokio::test]
async fn test_persistent_cache_batch() {
let cache = PersistentCache::in_memory().expect("Failed to create cache");
let cache = PersistentCache::in_memory().await.expect("Failed to create cache");
let items = vec![
("key1".to_string(), vec![1.0, 2.0]),
("key2".to_string(), vec![3.0, 4.0]),
];
cache.insert_batch(items).await;
let keys = vec!["key1".to_string(), "key2".to_string()];
@ -129,7 +154,7 @@ mod tests {
#[tokio::test]
async fn test_persistent_cache_invalidate() {
let cache = PersistentCache::in_memory().expect("Failed to create cache");
let cache = PersistentCache::in_memory().await.expect("Failed to create cache");
cache.insert("key1", vec![1.0]).await;
assert!(cache.get("key1").await.is_some());
@ -140,11 +165,10 @@ mod tests {
#[tokio::test]
async fn test_persistent_cache_clear() {
let cache = PersistentCache::in_memory().expect("Failed to create cache");
let cache = PersistentCache::in_memory().await.expect("Failed to create cache");
cache.insert("key1", vec![1.0]).await;
cache.insert("key2", vec![2.0]).await;
assert_eq!(cache.size(), 2);
cache.clear().await;
assert_eq!(cache.size(), 0);

View File

@ -66,7 +66,10 @@ impl From<reqwest::Error> for EmbeddingError {
if err.is_timeout() {
Self::Timeout(err.to_string())
} else if err.is_status() {
Self::HttpError(format!("HTTP {}: {}", err.status().unwrap(), err))
Self::HttpError(format!(
"HTTP {}: {err}",
err.status().map_or_else(|| "<unknown>".to_string(), |s| s.to_string())
))
} else {
Self::ApiError(err.to_string())
}

View File

@ -1,11 +1,12 @@
#[cfg(feature = "surrealdb-store")]
use std::path::Path;
#[cfg(feature = "surrealdb-store")]
use async_trait::async_trait;
#[cfg(feature = "surrealdb-store")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "surrealdb-store")]
use surrealdb::{
engine::local::{Db, Mem},
sql::Thing,
engine::local::{Db, Mem, RocksDb},
Surreal,
};
@ -16,13 +17,6 @@ use crate::{
traits::Embedding,
};
#[derive(Debug, Serialize, Deserialize)]
struct EmbeddingRecord {
id: Option<Thing>,
vector: Vec<f32>,
metadata: serde_json::Value,
}
pub struct SurrealDbStore {
db: Surreal<Db>,
table: String,
@ -42,21 +36,37 @@ impl SurrealDbStore {
})
}
/// RocksDB-backed persistent store.
pub async fn new_rocksdb(
path: impl AsRef<Path>,
table_name: &str,
config: VectorStoreConfig,
) -> Result<Self, EmbeddingError> {
let db = Surreal::new::<RocksDb>(path.as_ref()).await.map_err(|e| {
EmbeddingError::Initialization(format!("RocksDB SurrealDB connection failed: {e}"))
})?;
db.use_ns("embeddings")
.use_db("vectors")
.await
.map_err(|e| {
EmbeddingError::Initialization(format!("Failed to set namespace: {e}"))
})?;
Self::new(db, table_name, config).await
}
pub async fn new_memory(
table_name: &str,
config: VectorStoreConfig,
) -> Result<Self, EmbeddingError> {
let db = Surreal::new::<Mem>(()).await.map_err(|e| {
EmbeddingError::Initialization(format!("SurrealDB connection failed: {}", e))
EmbeddingError::Initialization(format!("SurrealDB connection failed: {e}"))
})?;
db.use_ns("embeddings")
.use_db("embeddings")
.await
.map_err(|e| {
EmbeddingError::Initialization(format!("Failed to set namespace: {}", e))
EmbeddingError::Initialization(format!("Failed to set namespace: {e}"))
})?;
Self::new(db, table_name, config).await
}
@ -64,17 +74,31 @@ impl SurrealDbStore {
if a.len() != b.len() {
return 0.0;
}
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if mag_a > 0.0 && mag_b > 0.0 {
dot / (mag_a * mag_b)
} else {
0.0
}
}
fn extract_vector(val: &serde_json::Value) -> Option<Vec<f32>> {
val.get("vector")
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
fn extract_metadata(val: &serde_json::Value) -> serde_json::Value {
val.get("metadata").cloned().unwrap_or(serde_json::Value::Null)
}
fn extract_key(val: &serde_json::Value) -> String {
val.get("key")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
}
#[async_trait]
@ -99,20 +123,17 @@ impl VectorStore for SurrealDbStore {
actual: embedding.len(),
});
}
let record = EmbeddingRecord {
id: None,
vector: embedding.clone(),
metadata,
};
let _: Option<EmbeddingRecord> = self
let payload = serde_json::json!({
"key": id,
"vector": embedding,
"metadata": metadata,
});
let _: Option<serde_json::Value> = self
.db
.update((self.table.as_str(), id))
.content(record)
.content(payload)
.await
.map_err(|e| EmbeddingError::StoreError(format!("Upsert failed: {}", e)))?;
.map_err(|e| EmbeddingError::StoreError(format!("Upsert failed: {e}")))?;
Ok(())
}
@ -128,31 +149,32 @@ impl VectorStore for SurrealDbStore {
actual: embedding.len(),
});
}
let all_records: Vec<EmbeddingRecord> = self
let all_records: Vec<serde_json::Value> = self
.db
.select(&self.table)
.await
.map_err(|e| EmbeddingError::StoreError(format!("Search failed: {}", e)))?;
.map_err(|e| EmbeddingError::StoreError(format!("Search failed: {e}")))?;
let mut scored_results: Vec<(String, f32, serde_json::Value)> = all_records
let mut scored: Vec<(String, f32, serde_json::Value)> = all_records
.into_iter()
.filter_map(|record| {
let id = record.id?.id.to_string();
let score = self.compute_cosine_similarity(embedding, &record.vector);
Some((id, score, record.metadata))
let id = Self::extract_key(&record);
let vector = Self::extract_vector(&record)?;
let metadata = Self::extract_metadata(&record);
let score = self.compute_cosine_similarity(embedding, &vector);
Some((id, score, metadata))
})
.collect();
scored_results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
if let Some(f) = filter {
if let Some(min_score) = f.min_score {
scored_results.retain(|(_, score, _)| *score >= min_score);
scored.retain(|(_, score, _)| *score >= min_score);
}
}
let results = scored_results
Ok(scored
.into_iter()
.take(limit)
.map(|(id, score, metadata)| SearchResult {
@ -161,33 +183,34 @@ impl VectorStore for SurrealDbStore {
embedding: None,
metadata,
})
.collect();
Ok(results)
.collect())
}
async fn get(&self, id: &str) -> Result<Option<SearchResult>, EmbeddingError> {
let record: Option<EmbeddingRecord> = self
let record: Option<serde_json::Value> = self
.db
.select((self.table.as_str(), id))
.await
.map_err(|e| EmbeddingError::StoreError(format!("Get failed: {}", e)))?;
.map_err(|e| EmbeddingError::StoreError(format!("Get failed: {e}")))?;
Ok(record.map(|r| SearchResult {
id: id.to_string(),
score: 1.0,
embedding: Some(r.vector),
metadata: r.metadata,
Ok(record.map(|r| {
let vector = Self::extract_vector(&r);
let metadata = Self::extract_metadata(&r);
SearchResult {
id: id.to_string(),
score: 1.0,
embedding: vector,
metadata,
}
}))
}
async fn delete(&self, id: &str) -> Result<bool, EmbeddingError> {
let result: Option<EmbeddingRecord> = self
let result: Option<serde_json::Value> = self
.db
.delete((self.table.as_str(), id))
.await
.map_err(|e| EmbeddingError::StoreError(format!("Delete failed: {}", e)))?;
.map_err(|e| EmbeddingError::StoreError(format!("Delete failed: {e}")))?;
Ok(result.is_some())
}
@ -196,12 +219,11 @@ impl VectorStore for SurrealDbStore {
}
async fn count(&self) -> Result<usize, EmbeddingError> {
let records: Vec<EmbeddingRecord> = self
let records: Vec<serde_json::Value> = self
.db
.select(&self.table)
.await
.map_err(|e| EmbeddingError::StoreError(format!("Count failed: {}", e)))?;
.map_err(|e| EmbeddingError::StoreError(format!("Count failed: {e}")))?;
Ok(records.len())
}
}
@ -253,11 +275,10 @@ mod tests {
vec![0.0, 1.0, 0.0],
vec![0.5, 0.5, 0.0],
];
for (i, embedding) in embeddings.iter().enumerate() {
store
.upsert(
&format!("id_{}", i),
&format!("id_{i}"),
embedding,
serde_json::json!({"idx": i}),
)
@ -266,10 +287,7 @@ mod tests {
}
let query = vec![1.0, 0.0, 0.0];
let results = store
.search(&query, 2, None)
.await
.expect("Failed to search");
let results = store.search(&query, 2, None).await.expect("Failed to search");
assert_eq!(results.len(), 2);
assert_eq!(results[0].id, "id_0");

View File

@ -0,0 +1,27 @@
[package]
name = "stratum-graph"
version = "0.1.0"
edition.workspace = true
description = "Knowledge domain: ActionGraph, ActionNode, Capability types and graph algorithms"
license.workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
uuid = { workspace = true }
surrealdb = { workspace = true, optional = true }
[features]
default = []
mem-store = ["surrealdb", "surrealdb/kv-mem"]
surrealkv-store = ["surrealdb", "surrealdb/kv-surrealkv"]
[dev-dependencies]
tokio-test = { workspace = true }
tempfile = { workspace = true }

View File

@ -0,0 +1,402 @@
use std::collections::{HashMap, HashSet};
use anyhow::{anyhow, Result};
use crate::types::{ActionNode, Capability, NodeId};
#[derive(Debug, Default)]
pub struct ActionGraph {
nodes: HashMap<NodeId, ActionNode>,
/// capability → set of nodes that produce it
producers: HashMap<Capability, HashSet<NodeId>>,
/// capability → set of nodes that consume it
consumers: HashMap<Capability, HashSet<NodeId>>,
/// (NATS subject pattern, entry NodeId)
trigger_index: Vec<(String, NodeId)>,
}
impl ActionGraph {
/// Build an ActionGraph from a set of already-parsed nodes.
pub fn from_nodes(nodes: Vec<ActionNode>) -> Result<Self> {
let mut g = Self::default();
for node in nodes {
g.insert_node(node);
}
g.check_cycles()?;
Ok(g)
}
fn insert_node(&mut self, node: ActionNode) {
for cap in node.output_schemas.keys() {
self.producers
.entry(cap.clone())
.or_default()
.insert(node.id.clone());
}
for cap in node.input_schemas.keys() {
self.consumers
.entry(cap.clone())
.or_default()
.insert(node.id.clone());
}
for pattern in &node.triggers {
self.trigger_index.push((pattern.clone(), node.id.clone()));
}
self.nodes.insert(node.id.clone(), node);
}
fn remove_node(&mut self, id: &NodeId) -> Option<ActionNode> {
let node = self.nodes.remove(id)?;
for cap in node.output_schemas.keys() {
if let Some(set) = self.producers.get_mut(cap) {
set.remove(id);
}
}
for cap in node.input_schemas.keys() {
if let Some(set) = self.consumers.get_mut(cap) {
set.remove(id);
}
}
self.trigger_index.retain(|(_, nid)| nid != id);
Some(node)
}
/// Apply a hot-reload update for a single file.
/// `new_node = None` means the file was deleted.
/// Returns the NodeIds that changed.
pub fn apply_update(&mut self, new_node: Option<ActionNode>) -> Result<Vec<NodeId>> {
let changed_id = match &new_node {
Some(n) => n.id.clone(),
None => return Ok(vec![]),
};
self.remove_node(&changed_id);
if let Some(node) = new_node {
self.insert_node(node);
}
self.check_cycles()?;
Ok(vec![changed_id])
}
/// Return nodes currently in the graph — used for observability.
pub fn nodes(&self) -> &HashMap<NodeId, ActionNode> {
&self.nodes
}
/// Topological plan from entry nodes matched by NATS subject pattern.
/// Returns `Vec<Vec<NodeId>>` where each inner Vec is a parallel stage.
pub fn plan(&self, subject: &str, _payload: &serde_json::Value) -> Result<Vec<Vec<NodeId>>> {
let roots: Vec<NodeId> = self
.trigger_index
.iter()
.filter(|(pattern, _)| subject_matches(pattern, subject))
.map(|(_, id)| id.clone())
.collect();
if roots.is_empty() {
return Err(anyhow!("no entry nodes match NATS subject '{subject}'"));
}
self.toposort_from(&roots)
}
fn toposort_from(&self, roots: &[NodeId]) -> Result<Vec<Vec<NodeId>>> {
// Collect all reachable nodes (BFS) to build a subgraph
let mut reachable: HashSet<NodeId> = HashSet::new();
let mut queue: Vec<NodeId> = roots.to_vec();
while let Some(id) = queue.pop() {
if !reachable.insert(id.clone()) {
continue;
}
let caps: Vec<_> = self
.nodes
.get(&id)
.map(|n| n.output_schemas.keys().cloned().collect())
.unwrap_or_default();
for cap in caps {
let downstream: Vec<_> = self
.consumers
.get(&cap)
.map(|c| c.iter().cloned().collect())
.unwrap_or_default();
queue.extend(downstream.into_iter().filter(|c| !reachable.contains(c)));
}
}
// Assign depth via longest-path in DAG
let mut memo: HashMap<NodeId, usize> = HashMap::new();
for id in &reachable {
self.node_depth(id, &mut memo);
}
// Group by depth
let max_depth = memo.values().copied().max().unwrap_or(0);
let mut stages: Vec<Vec<NodeId>> = vec![vec![]; max_depth + 1];
for id in &reachable {
let depth = memo[id];
stages[depth].push(id.clone());
}
// Remove empty stages (shouldn't happen but be defensive)
stages.retain(|s| !s.is_empty());
Ok(stages)
}
fn node_depth(&self, id: &NodeId, memo: &mut HashMap<NodeId, usize>) -> usize {
if let Some(&d) = memo.get(id) {
return d;
}
let node = match self.nodes.get(id) {
Some(n) => n,
None => {
memo.insert(id.clone(), 0);
return 0;
}
};
// Depth = 1 + max depth of all nodes that produce our required capabilities
let mut max_dep_depth = 0usize;
for cap in node.input_schemas.keys() {
if let Some(producers) = self.producers.get(cap) {
for prod_id in producers {
let d = self.node_depth(prod_id, memo);
max_dep_depth = max_dep_depth.max(d + 1);
}
}
}
memo.insert(id.clone(), max_dep_depth);
max_dep_depth
}
fn check_cycles(&self) -> Result<()> {
// DFS cycle detection
let mut visited: HashSet<NodeId> = HashSet::new();
let mut stack: HashSet<NodeId> = HashSet::new();
for id in self.nodes.keys() {
if !visited.contains(id) {
self.dfs_cycle(id, &mut visited, &mut stack)?;
}
}
Ok(())
}
fn dfs_cycle(
&self,
id: &NodeId,
visited: &mut HashSet<NodeId>,
stack: &mut HashSet<NodeId>,
) -> Result<()> {
visited.insert(id.clone());
stack.insert(id.clone());
let caps: Vec<_> = self
.nodes
.get(id)
.map(|n| n.output_schemas.keys().cloned().collect())
.unwrap_or_default();
for cap in caps {
let downstream: Vec<_> = self
.consumers
.get(&cap)
.map(|c| c.iter().cloned().collect())
.unwrap_or_default();
for next in &downstream {
if stack.contains(next) {
return Err(anyhow!(
"cycle detected involving nodes '{id}' and '{next}'"
));
}
if !visited.contains(next) {
self.dfs_cycle(next, visited, stack)?;
}
}
}
stack.remove(id);
Ok(())
}
}
/// Match a NATS subject against a pattern supporting `*` (single token) and `>` (multi-token).
fn subject_matches(pattern: &str, subject: &str) -> bool {
let p_parts: Vec<&str> = pattern.split('.').collect();
let s_parts: Vec<&str> = subject.split('.').collect();
let mut pi = 0;
let mut si = 0;
while pi < p_parts.len() && si < s_parts.len() {
match p_parts[pi] {
">" => return true,
"*" => {
pi += 1;
si += 1;
}
tok => {
if tok != s_parts[si] {
return false;
}
pi += 1;
si += 1;
}
}
}
pi == p_parts.len() && si == s_parts.len()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{BackoffStrategy, RetryPolicy};
use std::path::PathBuf;
fn retry() -> RetryPolicy {
RetryPolicy {
max: 3,
backoff_secs: 5,
strategy: BackoffStrategy::Exponential,
}
}
fn cap(s: &str) -> Capability {
Capability(s.to_owned())
}
fn cap_schema(s: &str) -> (Capability, PathBuf) {
(
Capability(s.to_owned()),
PathBuf::from(format!("schemas/{s}.ncl")),
)
}
fn node(id: &str, inputs: &[&str], outputs: &[&str], triggers: &[&str]) -> ActionNode {
ActionNode {
id: NodeId(id.to_owned()),
handler: PathBuf::from(format!("scripts/nu/{id}.nu")),
input_schemas: inputs.iter().map(|c| cap_schema(c)).collect(),
output_schemas: outputs.iter().map(|c| cap_schema(c)).collect(),
compensate: None,
retry: retry(),
timeout_secs: 120,
atomic: true,
triggers: triggers.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn test_toposort_linear() {
let nodes = vec![
node("lint", &[], &["linted"], &["dev.crate.*.modified"]),
node("build", &["linted"], &["built"], &[]),
node("install", &["built"], &["installed"], &[]),
];
let g = ActionGraph::from_nodes(nodes).unwrap();
let stages = g
.plan("dev.crate.foo.modified", &serde_json::Value::Null)
.unwrap();
assert_eq!(stages.len(), 3);
assert_eq!(stages[0], vec![NodeId("lint".into())]);
assert_eq!(stages[1], vec![NodeId("build".into())]);
assert_eq!(stages[2], vec![NodeId("install".into())]);
}
#[test]
fn test_toposort_parallel() {
// lint and fmt are both stage-0 (no inputs), build depends on both
let nodes = vec![
node("lint", &[], &["linted"], &["dev.crate.*.modified"]),
node("fmt", &[], &["formatted"], &["dev.crate.*.modified"]),
node("build", &["linted", "formatted"], &["built"], &[]),
];
let g = ActionGraph::from_nodes(nodes).unwrap();
let stages = g
.plan("dev.crate.foo.modified", &serde_json::Value::Null)
.unwrap();
// stage[0] must have both lint and fmt (order may vary)
let mut stage0 = stages[0].clone();
stage0.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(stage0, vec![NodeId("fmt".into()), NodeId("lint".into())]);
assert_eq!(stages[1], vec![NodeId("build".into())]);
}
#[test]
fn test_cycle_detection() {
// a → b → a (cycle via capabilities)
let nodes = vec![
node("a", &["cap-b"], &["cap-a"], &["trigger"]),
node("b", &["cap-a"], &["cap-b"], &[]),
];
let err = ActionGraph::from_nodes(nodes).unwrap_err();
assert!(err.to_string().contains("cycle"));
}
#[test]
fn test_plan_by_subject() {
let nodes = vec![
node("lint", &[], &["linted"], &["dev.crate.*.modified"]),
node("deploy", &[], &["deployed"], &["prod.deploy.requested"]),
];
let g = ActionGraph::from_nodes(nodes).unwrap();
let stages = g
.plan("dev.crate.my-crate.modified", &serde_json::Value::Null)
.unwrap();
assert_eq!(stages[0], vec![NodeId("lint".into())]);
let err = g
.plan("prod.deploy.requested", &serde_json::Value::Null)
.unwrap();
assert_eq!(err[0], vec![NodeId("deploy".into())]);
}
#[test]
fn test_apply_update_hot_reload() {
let nodes = vec![node("lint", &[], &["linted"], &["dev.*"])];
let mut g = ActionGraph::from_nodes(nodes).unwrap();
// Update lint to also produce "formatted"
let updated = ActionNode {
id: NodeId("lint".into()),
handler: PathBuf::from("scripts/nu/lint.nu"),
input_schemas: HashMap::new(),
output_schemas: [cap_schema("linted"), cap_schema("formatted")]
.into_iter()
.collect(),
compensate: None,
retry: retry(),
timeout_secs: 120,
atomic: true,
triggers: vec!["dev.*".to_string()],
};
let changed = g.apply_update(Some(updated)).unwrap();
assert_eq!(changed, vec![NodeId("lint".into())]);
// Verify the graph reflects the update
let n = g.nodes().get(&NodeId("lint".into())).unwrap();
assert!(n.output_schemas.contains_key(&cap("formatted")));
}
#[test]
fn test_subject_wildcard_star() {
assert!(subject_matches(
"dev.crate.*.modified",
"dev.crate.foo.modified"
));
assert!(!subject_matches(
"dev.crate.*.modified",
"dev.crate.foo.bar.modified"
));
}
#[test]
fn test_subject_wildcard_gt() {
assert!(subject_matches("dev.>", "dev.crate.foo.modified"));
assert!(subject_matches("dev.>", "dev.x"));
}
}

View File

@ -0,0 +1,9 @@
pub mod graph;
pub mod repository;
pub mod types;
pub use graph::ActionGraph;
pub use repository::{GraphRepository, InMemoryGraphRepository};
#[cfg(any(feature = "mem-store", feature = "surrealkv-store"))]
pub use repository::SurrealGraphRepository;
pub use types::{ActionNode, BackoffStrategy, Capability, NodeId, RetryPolicy};

View File

@ -0,0 +1,151 @@
use anyhow::Result;
use async_trait::async_trait;
use std::collections::HashMap;
use tokio::sync::RwLock;
use crate::types::{ActionNode, NodeId};
#[cfg(any(feature = "mem-store", feature = "surrealkv-store"))]
pub use surreal_repository::SurrealGraphRepository;
#[cfg(any(feature = "mem-store", feature = "surrealkv-store"))]
mod surreal_repository {
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::Serialize;
#[cfg(feature = "mem-store")]
use surrealdb::engine::local::Mem;
use surrealdb::{engine::local::Db, Surreal};
use crate::types::{ActionNode, NodeId};
use super::GraphRepository;
fn to_json<T: Serialize>(v: &T) -> Result<serde_json::Value> {
serde_json::to_value(v).context("serializing to serde_json::Value")
}
fn from_json<T: serde::de::DeserializeOwned>(v: serde_json::Value) -> Result<T> {
serde_json::from_value(v).context("deserializing from serde_json::Value")
}
pub struct SurrealGraphRepository {
db: Surreal<Db>,
}
impl SurrealGraphRepository {
#[cfg(feature = "mem-store")]
pub async fn new_mem() -> Result<Self> {
let db = Surreal::new::<Mem>(())
.await
.context("creating in-memory SurrealDB for graph")?;
db.use_ns("stratum").use_db("graph").await?;
Ok(Self { db })
}
#[cfg(feature = "surrealkv-store")]
pub async fn new_surrealkv(path: impl AsRef<std::path::Path>) -> Result<Self> {
use surrealdb::engine::local::SurrealKv;
let db = Surreal::new::<SurrealKv>(path.as_ref())
.await
.context("creating SurrealKV SurrealDB for graph")?;
db.use_ns("stratum").use_db("graph").await?;
Ok(Self { db })
}
}
#[async_trait]
impl GraphRepository for SurrealGraphRepository {
async fn upsert_node(&self, node: &ActionNode) -> Result<()> {
let id_str = node.id.0.clone();
let payload = to_json(node)?;
let _: Option<serde_json::Value> = self
.db
.upsert(("action_node_v1", id_str))
.content(payload)
.await
.context("upsert_node")?;
Ok(())
}
async fn get_node(&self, id: &NodeId) -> Result<Option<ActionNode>> {
let raw: Option<serde_json::Value> = self
.db
.select(("action_node_v1", id.0.clone()))
.await
.context("get_node")?;
raw.map(from_json).transpose()
}
async fn list_nodes(&self) -> Result<Vec<ActionNode>> {
let raw: Vec<serde_json::Value> = self
.db
.select("action_node_v1")
.await
.context("list_nodes")?;
raw.into_iter().map(from_json).collect()
}
async fn delete_node(&self, id: &NodeId) -> Result<()> {
let _: Option<serde_json::Value> = self
.db
.delete(("action_node_v1", id.0.clone()))
.await
.context("delete_node")?;
Ok(())
}
}
}
/// Read-only observability view over the in-memory graph.
/// Write persistence (pipeline runs, capabilities) belongs to stratum-state.
#[async_trait]
pub trait GraphRepository: Send + Sync {
async fn upsert_node(&self, node: &ActionNode) -> Result<()>;
async fn get_node(&self, id: &NodeId) -> Result<Option<ActionNode>>;
async fn list_nodes(&self) -> Result<Vec<ActionNode>>;
async fn delete_node(&self, id: &NodeId) -> Result<()>;
}
/// In-memory implementation — runtime observability snapshot.
pub struct InMemoryGraphRepository {
store: RwLock<HashMap<NodeId, ActionNode>>,
}
impl InMemoryGraphRepository {
pub fn new() -> Self {
Self {
store: RwLock::new(HashMap::new()),
}
}
}
impl Default for InMemoryGraphRepository {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl GraphRepository for InMemoryGraphRepository {
async fn upsert_node(&self, node: &ActionNode) -> Result<()> {
self.store
.write()
.await
.insert(node.id.clone(), node.clone());
Ok(())
}
async fn get_node(&self, id: &NodeId) -> Result<Option<ActionNode>> {
Ok(self.store.read().await.get(id).cloned())
}
async fn list_nodes(&self) -> Result<Vec<ActionNode>> {
Ok(self.store.read().await.values().cloned().collect())
}
async fn delete_node(&self, id: &NodeId) -> Result<()> {
self.store.write().await.remove(id);
Ok(())
}
}

View File

@ -0,0 +1,64 @@
use std::{collections::HashMap, path::PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct NodeId(pub String);
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Capability(pub String);
impl std::fmt::Display for Capability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionNode {
pub id: NodeId,
/// Path to handler script relative to action-nodes/
pub handler: PathBuf,
/// Capabilities this node requires as inputs
pub input_schemas: HashMap<Capability, PathBuf>,
/// Capabilities this node produces as outputs
pub output_schemas: HashMap<Capability, PathBuf>,
/// Optional compensation script for Saga rollback
pub compensate: Option<PathBuf>,
pub retry: RetryPolicy,
pub timeout_secs: u64,
pub atomic: bool,
/// NATS subject patterns that trigger this node as an entry point
pub triggers: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryPolicy {
pub max: u32,
pub backoff_secs: u64,
pub strategy: BackoffStrategy,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max: 3,
backoff_secs: 10,
strategy: BackoffStrategy::Exponential,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackoffStrategy {
Fixed,
Linear,
Exponential,
}

View File

@ -62,7 +62,7 @@ impl CircuitBreaker {
match self.state() {
CircuitState::Closed => true,
CircuitState::Open => {
if let Some(last_failure) = *self.last_failure_time.read().unwrap() {
if let Some(last_failure) = *self.last_failure_time.read().unwrap_or_else(|e| e.into_inner()) {
if last_failure.elapsed() >= self.config.reset_timeout {
self.state
.store(CircuitState::HalfOpen as u8, Ordering::SeqCst);
@ -77,7 +77,7 @@ impl CircuitBreaker {
}
pub fn record_success(&self) {
*self.last_success_time.write().unwrap() = Some(Instant::now());
*self.last_success_time.write().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
self.failure_count.store(0, Ordering::SeqCst);
if self.state() == CircuitState::HalfOpen {
@ -91,7 +91,7 @@ impl CircuitBreaker {
}
pub fn record_failure(&self) {
*self.last_failure_time.write().unwrap() = Some(Instant::now());
*self.last_failure_time.write().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
match self.state() {
CircuitState::Closed => {

View File

@ -0,0 +1,40 @@
[package]
name = "stratum-orchestrator"
version = "0.1.0"
edition.workspace = true
description = "Graph-driven workflow orchestrator with Saga compensation"
license.workspace = true
[[bin]]
name = "orchestrator"
path = "src/bin/orchestrator.rs"
[dependencies]
stratum-graph = { path = "../stratum-graph" }
stratum-state = { path = "../stratum-state" }
platform-nats = { path = "../platform-nats" }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
tokio = { workspace = true }
tokio-util = { workspace = true }
async-trait = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
dashmap = { workspace = true }
notify = { workspace = true }
cedar-policy = { workspace = true }
regex = { workspace = true }
axum = { workspace = true }
tower-http = { workspace = true }
reqwest = { workspace = true }
bytes = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
tempfile = { workspace = true }
tracing-subscriber = { workspace = true }

View File

@ -0,0 +1,134 @@
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use cedar_policy::{Authorizer, Context as CedarContext, Entities, PolicySet, Request};
use tracing::debug;
pub struct CedarAuthorizer {
policy_set: PolicySet,
authorizer: Authorizer,
}
impl CedarAuthorizer {
/// Load all .cedar policy files from a directory.
pub fn load_from_dir(dir: &Path) -> Result<Self> {
let entries = std::fs::read_dir(dir)
.with_context(|| format!("reading Cedar policy dir '{}'", dir.display()))?;
let mut combined = String::new();
for entry in entries {
let entry = entry.context("reading Cedar dir entry")?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("cedar") {
continue;
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("reading Cedar policy '{}'", path.display()))?;
combined.push_str(&content);
combined.push('\n');
}
if combined.trim().is_empty() {
return Err(anyhow!(
"no .cedar policy files found in '{}'",
dir.display()
));
}
let policy_set: PolicySet = combined
.parse()
.map_err(|e| anyhow!("parsing Cedar policies from '{}': {e}", dir.display()))?;
Ok(Self {
policy_set,
authorizer: Authorizer::new(),
})
}
/// Authorize an action. Returns Ok(()) if permitted, Err if denied.
pub fn authorize(&self, principal: &str, action: &str, resource: &str) -> Result<()> {
let principal_entity: cedar_policy::EntityUid = format!("User::\"{principal}\"")
.parse()
.with_context(|| format!("parsing principal EntityUid: User::\"{principal}\""))?;
let action_entity: cedar_policy::EntityUid = format!("Action::\"{action}\"")
.parse()
.with_context(|| format!("parsing action EntityUid: Action::\"{action}\""))?;
let resource_entity: cedar_policy::EntityUid = resource
.parse()
.with_context(|| format!("parsing resource EntityUid: {resource}"))?;
let request = Request::new(
principal_entity,
action_entity,
resource_entity,
CedarContext::empty(),
None,
)
.map_err(|e| anyhow!("building Cedar request: {e}"))?;
let response =
self.authorizer
.is_authorized(&request, &self.policy_set, &Entities::empty());
debug!(
"Cedar: {principal} {action} {resource} → {:?}",
response.decision()
);
match response.decision() {
cedar_policy::Decision::Allow => Ok(()),
cedar_policy::Decision::Deny => Err(anyhow!(
"Cedar denied: principal '{principal}' action '{action}' resource '{resource}'"
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn write_policy(dir: &TempDir, name: &str, content: &str) {
let path = dir.path().join(name);
let mut f = std::fs::File::create(path).unwrap();
f.write_all(content.as_bytes()).unwrap();
}
const PERMIT_ORCHESTRATOR: &str = r#"
permit(
principal == User::"orchestrator",
action == Action::"execute",
resource == Node::"lint"
);"#;
const FORBID_ALL: &str = r#"
forbid(principal, action, resource);"#;
#[test]
fn test_permit_allows() {
let dir = TempDir::new().unwrap();
write_policy(&dir, "permit.cedar", PERMIT_ORCHESTRATOR);
let authz = CedarAuthorizer::load_from_dir(dir.path()).unwrap();
authz
.authorize("orchestrator", "execute", "Node::\"lint\"")
.unwrap();
}
#[test]
fn test_deny_returns_err() {
let dir = TempDir::new().unwrap();
write_policy(&dir, "forbid.cedar", FORBID_ALL);
let authz = CedarAuthorizer::load_from_dir(dir.path()).unwrap();
let err = authz
.authorize("orchestrator", "execute", "Node::\"lint\"")
.unwrap_err();
assert!(err.to_string().contains("Cedar denied"));
}
}

View File

@ -0,0 +1,5 @@
pub mod cedar;
pub mod vault;
pub use cedar::CedarAuthorizer;
pub use vault::{NodeCredentials, VaultClient, VaultConfig, VaultHealth};

View File

@ -0,0 +1,175 @@
use std::collections::HashMap;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use stratum_graph::types::NodeId;
use tracing::warn;
#[derive(Debug, Clone)]
pub struct NodeCredentials {
/// Injected as child process environment variables
pub env_vars: HashMap<String, String>,
/// Lease ID for revocation on failure/timeout
pub lease_id: String,
}
#[derive(Debug, Clone)]
pub struct VaultConfig {
pub url: String,
pub token: String,
}
pub struct VaultClient {
config: VaultConfig,
http: reqwest::Client,
}
impl VaultClient {
pub fn new(config: VaultConfig) -> Self {
Self {
config,
http: reqwest::Client::new(),
}
}
/// Fetch credentials for a node with the specified TTL.
/// Credentials are returned as env vars to inject into the child process.
pub async fn get_node_credentials(
&self,
node_id: &NodeId,
ttl_secs: u64,
) -> Result<NodeCredentials> {
let url = format!("{}/v1/auth/token/create", self.config.url);
#[derive(Serialize)]
struct TokenCreateRequest {
ttl: String,
display_name: String,
renewable: bool,
}
#[derive(Deserialize)]
struct TokenCreateResponse {
auth: TokenAuth,
}
#[derive(Deserialize)]
struct TokenAuth {
client_token: String,
#[allow(dead_code)]
lease_duration: u64,
accessor: String,
}
let body = TokenCreateRequest {
ttl: format!("{ttl_secs}s"),
display_name: format!("stratum-node-{}", node_id.0),
renewable: false,
};
let resp = self
.http
.post(&url)
.header("X-Vault-Token", &self.config.token)
.json(&body)
.send()
.await
.context("sending token create request to Vault")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"Vault token create failed ({status}) for node '{}': {body}",
node_id.0
));
}
let token_resp: TokenCreateResponse =
resp.json().await.context("parsing Vault response")?;
let mut env_vars = HashMap::new();
env_vars.insert("VAULT_TOKEN".to_string(), token_resp.auth.client_token);
env_vars.insert("VAULT_ADDR".to_string(), self.config.url.clone());
Ok(NodeCredentials {
env_vars,
lease_id: token_resp.auth.accessor,
})
}
/// Revoke a credential lease. Called on node failure to prevent dangling TTL tokens.
pub async fn revoke_credentials(&self, lease_id: &str) -> Result<()> {
let url = format!("{}/v1/auth/token/revoke-accessor", self.config.url);
#[derive(Serialize)]
struct RevokeRequest<'a> {
accessor: &'a str,
}
let resp = self
.http
.post(&url)
.header("X-Vault-Token", &self.config.token)
.json(&RevokeRequest { accessor: lease_id })
.send()
.await
.with_context(|| format!("revoking Vault accessor '{lease_id}'"))?;
if !resp.status().is_success() {
let status = resp.status();
warn!("Vault revoke failed ({status}) for accessor '{lease_id}' — token will expire by TTL");
}
Ok(())
}
/// Health check — GET /v1/sys/health.
pub async fn health_check(&self) -> Result<VaultHealth> {
#[derive(Deserialize)]
struct VaultHealthResponse {
initialized: bool,
sealed: bool,
standby: bool,
}
let url = format!("{}/v1/sys/health", self.config.url);
let resp = self
.http
.get(&url)
.header("X-Vault-Token", &self.config.token)
.send()
.await
.context("Vault health check")?;
// Vault returns 200 (active), 429 (standby), 473 (performance standby), 501/503
let status = resp.status();
if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
return Ok(VaultHealth::Sealed);
}
let body: VaultHealthResponse = resp.json().await.context("parsing Vault health")?;
if !body.initialized {
return Ok(VaultHealth::Uninitialized);
}
if body.sealed {
return Ok(VaultHealth::Sealed);
}
if body.standby {
return Ok(VaultHealth::Standby);
}
Ok(VaultHealth::Active)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VaultHealth {
Active,
Standby,
Sealed,
Uninitialized,
Unreachable,
}

View File

@ -0,0 +1,358 @@
use std::{path::PathBuf, sync::Arc, time::Duration};
use anyhow::{anyhow, Context, Result};
use axum::{
extract::{Path, State},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use stratum_orchestrator::{
auth::{CedarAuthorizer, VaultClient, VaultConfig, VaultHealth},
context::PipelineContext,
executor::{AgentExecutor, NuExecutor},
graph::{load_graph_from_dir, GraphWatcher},
runner::StageRunner,
};
use stratum_state::InMemoryStateTracker;
use tokio::sync::{RwLock, Semaphore};
use tracing::{error, info, warn};
use uuid::Uuid;
// ─── Config ──────────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct OrchestratorConfig {
#[allow(dead_code)]
surrealdb_url: String,
nats_url: String,
#[allow(dead_code)]
zot_url: String,
vault_url: String,
vault_token: Option<String>,
action_nodes_dir: String,
schemas_dir: String,
cedar_policy_dir: String,
#[serde(default = "default_log_level")]
log_level: String,
#[serde(default = "default_http_port")]
http_port: u16,
#[serde(default)]
trusted_nkeys: Vec<String>,
#[serde(default)]
features: Features,
}
#[derive(Debug, Default, Deserialize)]
struct Features {
#[serde(default)]
nkey_auth_required: bool,
#[serde(default = "default_true")]
cedar_auth_required: bool,
#[serde(default = "default_true")]
#[allow(dead_code)]
schema_validation: bool,
#[serde(default)]
#[allow(dead_code)]
agent_executor_enabled: bool,
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_http_port() -> u16 {
9088
}
fn default_true() -> bool {
true
}
impl OrchestratorConfig {
fn load(path: &std::path::Path) -> Result<Self> {
// Export from Nickel, parse JSON
let output = std::process::Command::new("nickel")
.arg("export")
.arg("--format")
.arg("json")
.arg(path)
.output()
.context("running nickel export on orchestrator config")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"orchestrator config failed Nickel typecheck:\n{}",
stderr.trim()
));
}
serde_json::from_slice(&output.stdout).context("parsing orchestrator config JSON")
}
}
// ─── App State ───────────────────────────────────────────────────────────────
#[derive(Clone)]
struct AppState {
#[allow(dead_code)]
runner: Arc<StageRunner>,
agent_exec: Arc<AgentExecutor>,
graph: Arc<RwLock<stratum_graph::ActionGraph>>,
/// Per-target semaphore — Semaphore::new(1) enforces FIFO serialization per trigger subject
#[allow(dead_code)]
pipeline_locks: Arc<DashMap<String, Arc<Semaphore>>>,
}
// ─── HTTP Handlers ────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
node_count: usize,
vault: String,
}
async fn health_handler(State(state): State<AppState>) -> impl IntoResponse {
let node_count = state.graph.read().await.nodes().len();
Json(HealthResponse {
status: "ok",
node_count,
vault: "connected".to_string(),
})
}
#[derive(Deserialize)]
struct AgentResult {
value: serde_json::Value,
}
async fn agent_result_handler(
Path(correlation_id): Path<Uuid>,
State(state): State<AppState>,
Json(body): Json<AgentResult>,
) -> impl IntoResponse {
match state.agent_exec.resolve(correlation_id, body.value) {
Ok(()) => (axum::http::StatusCode::OK, "accepted"),
Err(e) => {
warn!("agent result error: {e}");
(axum::http::StatusCode::NOT_FOUND, "no pending request")
}
}
}
// ─── Pipeline Task ────────────────────────────────────────────────────────────
async fn run_with_semaphore(
sem: Arc<Semaphore>,
runner: Arc<StageRunner>,
ctx: Arc<PipelineContext>,
subject: String,
) {
match sem.acquire().await {
Ok(_permit) => {
info!(run_id = %ctx.run_id, %subject, "pipeline acquired lock, starting");
if let Err(e) = runner.run_pipeline(ctx).await {
error!("pipeline error on '{subject}': {e}");
}
}
Err(_) => {
error!("semaphore closed for subject '{subject}' — pipeline dropped");
}
}
}
// ─── Main ─────────────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() -> Result<()> {
// 1. Load config
let config_path = std::env::args()
.nth(2)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("config/orchestrator-config.ncl"));
let cfg = OrchestratorConfig::load(&config_path)?;
tracing_subscriber::fmt()
.with_env_filter(&cfg.log_level)
.init();
info!("orchestrator starting — config: {}", config_path.display());
// 2. State tracker (in-memory for now; SurrealDB in T0-B feature)
let state: Arc<dyn stratum_state::StateTracker> = Arc::new(InMemoryStateTracker::new());
// 3. Connect NATS
let nats_cfg = platform_nats::NatsConfig {
url: cfg.nats_url.clone(),
nkey_seed: None,
stream_name: "stratum-events".to_string(),
consumer_name: "orchestrator".to_string(),
subjects: vec!["dev.>".to_string(), "stratum.>".to_string()],
require_signed_messages: cfg.features.nkey_auth_required,
trusted_nkeys: cfg.trusted_nkeys.clone(),
};
let event_stream = Arc::new(
platform_nats::EventStream::connect(&nats_cfg)
.await
.context("connecting to NATS")?,
);
info!("connected to NATS at {}", cfg.nats_url);
// 4. Load ActionGraph from action-nodes/
let nodes_dir = PathBuf::from(&cfg.action_nodes_dir);
let initial_graph = load_graph_from_dir(&nodes_dir)?;
let graph = Arc::new(RwLock::new(initial_graph));
info!(
"loaded {} nodes from '{}'",
graph.read().await.nodes().len(),
nodes_dir.display()
);
// 5. Start hot-reload watcher
let _watcher = GraphWatcher::new(nodes_dir.clone(), Arc::clone(&graph))?;
info!("watching '{}' for hot-reload", nodes_dir.display());
// 6. Initialize Cedar authorizer
let cedar = Arc::new(
CedarAuthorizer::load_from_dir(&PathBuf::from(&cfg.cedar_policy_dir))
.context("loading Cedar policies")?,
);
// 7. Initialize Vault client
let vault_token = cfg
.vault_token
.clone()
.unwrap_or_else(|| std::env::var("VAULT_TOKEN").unwrap_or_default());
let vault = Arc::new(VaultClient::new(VaultConfig {
url: cfg.vault_url.clone(),
token: vault_token,
}));
// Health-check Vault
match vault.health_check().await {
Ok(VaultHealth::Active) => info!("Vault: active"),
Ok(health) => warn!("Vault: {:?}", health),
Err(e) => warn!("Vault health check failed (continuing): {e}"),
}
// 8. Executors
let nu_exec = Arc::new(NuExecutor::new(PathBuf::from(".")));
let agent_exec = Arc::new(AgentExecutor::new(
format!("http://localhost:{}", cfg.http_port),
Arc::clone(&event_stream),
));
// 9. StageRunner
let runner = Arc::new(StageRunner {
graph: Arc::clone(&graph),
executor: nu_exec,
state: Arc::clone(&state),
auth: cedar,
vault,
cedar_required: cfg.features.cedar_auth_required,
});
let pipeline_locks: Arc<DashMap<String, Arc<Semaphore>>> = Arc::new(DashMap::new());
let app_state = AppState {
runner: Arc::clone(&runner),
agent_exec: Arc::clone(&agent_exec),
graph: Arc::clone(&graph),
pipeline_locks: Arc::clone(&pipeline_locks),
};
// 10. HTTP server (health + agent callback)
let app = Router::new()
.route("/health", get(health_handler))
.route("/agent/result/:correlation_id", post(agent_result_handler))
.with_state(app_state);
let addr = format!("0.0.0.0:{}", cfg.http_port);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.with_context(|| format!("binding HTTP server to {addr}"))?;
info!("HTTP server listening on {addr}");
tokio::spawn(async move {
axum::serve(listener, app).await.expect("HTTP server error");
});
// 11. Graceful shutdown setup
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
tokio::spawn(async move {
if let Ok(()) = tokio::signal::ctrl_c().await {
info!("SIGINT received — shutting down");
let _ = shutdown_tx.send(true);
}
});
// 12. Event loop
info!("entering event loop");
loop {
if *shutdown_rx.borrow() {
info!("shutdown signal received — draining in-flight pipelines");
break;
}
let messages = match event_stream.pull_batch(10).await {
Ok(m) => m,
Err(e) => {
error!("pull_batch error: {e}");
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
};
// Small yield to avoid busy-loop when no messages
if messages.is_empty() {
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
for (subject, payload, msg) in messages {
let trigger_payload: serde_json::Value =
serde_json::from_slice(&payload).unwrap_or(serde_json::Value::Null);
let ctx = match PipelineContext::new(
subject.clone(),
trigger_payload,
Arc::clone(&state),
PathBuf::from(&cfg.schemas_dir),
)
.await
{
Ok(c) => Arc::new(c),
Err(e) => {
error!("failed to create PipelineContext for '{subject}': {e}");
let _ = msg.ack().await;
continue;
}
};
// Ack immediately after context creation — we own this message
if let Err(e) = msg.ack().await {
warn!("ack failed for '{subject}': {e}");
}
// Per-target semaphore: FIFO serialization within the same trigger subject.
// acquire() is called INSIDE the spawned task so tokio FIFO fairness applies.
let sem = pipeline_locks
.entry(subject.clone())
.or_insert_with(|| Arc::new(Semaphore::new(1)))
.clone();
let runner = Arc::clone(&runner);
tokio::spawn(run_with_semaphore(sem, runner, ctx, subject));
}
}
// Wait briefly for in-flight tasks (best-effort, max 30s)
tokio::time::sleep(Duration::from_secs(30)).await;
info!("orchestrator stopped");
Ok(())
}

View File

@ -0,0 +1,220 @@
use std::{collections::HashMap, path::Path, sync::Arc};
use anyhow::{anyhow, Context, Result};
use dashmap::DashMap;
use stratum_graph::types::Capability;
use stratum_state::{PipelineRun, PipelineRunId, StateTracker};
use tracing::debug;
pub struct PipelineContext {
pub run_id: PipelineRunId,
pub trigger_subject: String,
pub trigger_payload: serde_json::Value,
cache: DashMap<String, serde_json::Value>,
state: Arc<dyn StateTracker>,
schema_dir: std::path::PathBuf,
}
impl PipelineContext {
/// Creates a new context, writing the PipelineRun record to DB first.
pub async fn new(
subject: String,
payload: serde_json::Value,
state: Arc<dyn StateTracker>,
schema_dir: std::path::PathBuf,
) -> Result<Self> {
let run = PipelineRun::new(subject.clone(), payload.clone());
state
.create_run(&run)
.await
.context("creating pipeline run in DB")?;
Ok(Self {
run_id: run.id,
trigger_subject: subject,
trigger_payload: payload,
cache: DashMap::new(),
state,
schema_dir,
})
}
/// Deposit a capability: DB write first, then schema validation, then cache update.
pub async fn deposit(&self, cap: &Capability, value: serde_json::Value) -> Result<()> {
// 1. Persist to DB first — crash-safe
self.state
.deposit_capability(&self.run_id, cap, &value)
.await
.with_context(|| format!("depositing capability '{cap}' to DB"))?;
// 2. Schema validation (only if schema file exists)
let schema_path = self.schema_dir.join(format!("{}.ncl", cap.0));
if schema_path.exists() {
validate_against_schema(&schema_path, &value)
.with_context(|| format!("schema validation failed for capability '{cap}'"))?;
}
// 3. Update in-memory cache
self.cache.insert(cap.0.clone(), value);
debug!("deposited capability '{cap}'");
Ok(())
}
/// Extract a capability: cache first, DB fallback (crash recovery path).
pub async fn extract(&self, cap: &Capability) -> Result<serde_json::Value> {
if let Some(v) = self.cache.get(&cap.0) {
return Ok(v.clone());
}
// DB fallback — covers the crash-and-restart case
self.state
.load_capability(&self.run_id, cap)
.await
.with_context(|| format!("loading capability '{cap}' from DB"))?
.ok_or_else(|| {
anyhow!(
"capability '{cap}' not found in pipeline run {}",
self.run_id
)
})
}
/// Extract all required capabilities, returning a map.
pub async fn extract_inputs(
&self,
required: &[Capability],
) -> Result<HashMap<Capability, serde_json::Value>> {
let mut result = HashMap::with_capacity(required.len());
for cap in required {
let value = self
.extract(cap)
.await
.with_context(|| format!("extracting required input '{cap}'"))?;
result.insert(cap.clone(), value);
}
Ok(result)
}
}
/// Validate a JSON value against a Nickel schema file.
/// Runs `nickel export --format json <schema_path>` to get the JSON Schema,
/// then checks required fields are present.
///
/// Only called when schema file exists; silently skipped otherwise.
fn validate_against_schema(schema_path: &Path, value: &serde_json::Value) -> Result<()> {
let output = std::process::Command::new("nickel")
.arg("export")
.arg("--format")
.arg("json")
.arg(schema_path)
.output()
.with_context(|| {
format!(
"running nickel export on schema '{}'",
schema_path.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"schema '{}' failed to export: {}",
schema_path.display(),
stderr.trim()
));
}
let schema: serde_json::Value =
serde_json::from_slice(&output.stdout).context("parsing schema JSON from nickel export")?;
// Check required fields if the schema has a `required` array
if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
let obj = value.as_object().ok_or_else(|| {
anyhow!("capability value must be a JSON object when schema defines required fields")
})?;
for req in required {
if let Some(field) = req.as_str() {
if !obj.contains_key(field) {
return Err(anyhow!(
"required field '{field}' missing from capability value"
));
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use stratum_state::InMemoryStateTracker;
use tempfile::TempDir;
async fn make_ctx() -> (PipelineContext, TempDir) {
let dir = TempDir::new().unwrap();
let state = Arc::new(InMemoryStateTracker::new());
let ctx = PipelineContext::new(
"test.subject".to_string(),
serde_json::Value::Null,
state,
dir.path().to_path_buf(),
)
.await
.unwrap();
(ctx, dir)
}
#[tokio::test]
async fn test_deposit_and_extract() {
let (ctx, _dir) = make_ctx().await;
let cap = Capability("linted".to_string());
let value = serde_json::json!({"warnings": 0});
ctx.deposit(&cap, value.clone()).await.unwrap();
let extracted = ctx.extract(&cap).await.unwrap();
assert_eq!(extracted, value);
}
#[tokio::test]
async fn test_extract_missing_returns_err() {
let (ctx, _dir) = make_ctx().await;
let cap = Capability("missing".to_string());
let err = ctx.extract(&cap).await.unwrap_err();
assert!(err.to_string().contains("missing"));
}
#[tokio::test]
async fn test_db_fallback_on_cache_miss() {
let dir = TempDir::new().unwrap();
let state = Arc::new(InMemoryStateTracker::new());
let ctx1 = PipelineContext::new(
"test".to_string(),
serde_json::Value::Null,
Arc::clone(&state) as Arc<dyn StateTracker>,
dir.path().to_path_buf(),
)
.await
.unwrap();
let cap = Capability("linted".to_string());
let value = serde_json::json!({"ok": true});
ctx1.deposit(&cap, value.clone()).await.unwrap();
// Simulate restart: new context, same state — cache is empty
let ctx2 = PipelineContext {
run_id: ctx1.run_id.clone(),
trigger_subject: "test".to_string(),
trigger_payload: serde_json::Value::Null,
cache: DashMap::new(),
state: state as Arc<dyn StateTracker>,
schema_dir: dir.path().to_path_buf(),
};
let extracted = ctx2.extract(&cap).await.unwrap();
assert_eq!(extracted, value);
}
}

View File

@ -0,0 +1,93 @@
use std::{sync::Arc, time::Duration};
use anyhow::{anyhow, Result};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use stratum_state::{PipelineRunId, PipelineStatus, StateTracker};
use tokio::sync::oneshot;
use tracing::warn;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRequest {
pub node_id: String,
pub run_id: String,
pub correlation_id: Uuid,
pub callback_url: String,
pub payload: serde_json::Value,
}
pub struct AgentExecutor {
pending: DashMap<Uuid, oneshot::Sender<serde_json::Value>>,
callback_url: String,
nats: Arc<platform_nats::EventStream>,
}
impl AgentExecutor {
pub fn new(callback_url: String, nats: Arc<platform_nats::EventStream>) -> Self {
Self {
pending: DashMap::new(),
callback_url,
nats,
}
}
/// Dispatch a request to an AI agent via NATS, then suspend the pipeline
/// until the agent POSTs its result to the callback endpoint.
pub async fn dispatch(
&self,
request_payload: serde_json::Value,
node_id: &str,
timeout_secs: u64,
state: &dyn StateTracker,
run_id: &PipelineRunId,
) -> Result<serde_json::Value> {
let correlation_id = Uuid::new_v4();
let (tx, rx) = oneshot::channel();
self.pending.insert(correlation_id, tx);
state
.update_status(run_id, PipelineStatus::AwaitingAgent)
.await?;
let req = AgentRequest {
node_id: node_id.to_string(),
run_id: run_id.to_string(),
correlation_id,
callback_url: format!("{}/agent/result/{}", self.callback_url, correlation_id),
payload: request_payload,
};
let subject = format!("stratum.agent.request.{correlation_id}");
let payload = bytes::Bytes::from(serde_json::to_vec(&req)?);
self.nats
.publish(&subject, payload)
.await
.map_err(|e| anyhow!("failed to publish agent request: {e}"))?;
let result = tokio::time::timeout(Duration::from_secs(timeout_secs), rx)
.await
.map_err(|_| {
self.pending.remove(&correlation_id);
anyhow!("agent request {correlation_id} timed out after {timeout_secs}s")
})?
.map_err(|_| anyhow!("agent response channel closed unexpectedly"))?;
state.update_status(run_id, PipelineStatus::Running).await?;
Ok(result)
}
/// Called by the HTTP handler when an agent POSTs its result.
pub fn resolve(&self, correlation_id: Uuid, value: serde_json::Value) -> Result<()> {
let (_, tx) = self.pending.remove(&correlation_id).ok_or_else(|| {
anyhow!("no pending agent request for correlation_id {correlation_id}")
})?;
tx.send(value).map_err(|_| {
warn!("agent result for {correlation_id} arrived after pipeline timed out");
anyhow!("pipeline for {correlation_id} already timed out — result discarded")
})
}
}

View File

@ -0,0 +1,7 @@
pub mod agent;
pub mod nu;
pub mod sanitize;
pub use agent::AgentExecutor;
pub use nu::NuExecutor;
pub use sanitize::redact_secrets;

View File

@ -0,0 +1,126 @@
use std::{collections::HashMap, path::PathBuf, time::Duration};
use anyhow::{anyhow, Context, Result};
use stratum_graph::types::Capability;
use tokio::io::AsyncWriteExt;
use tracing::{debug, warn};
use stratum_state::PipelineRunId;
use crate::{
auth::NodeCredentials,
executor::sanitize::{redact_secrets, sanitize_env},
};
use stratum_graph::ActionNode;
pub struct NuExecutor {
scripts_base: PathBuf,
}
impl NuExecutor {
pub fn new(scripts_base: PathBuf) -> Self {
Self { scripts_base }
}
/// Execute a node's handler script.
///
/// - Inputs are passed as JSON on stdin
/// - Outputs are read as JSON from stdout
/// - Credentials are injected as environment variables
/// - Timeout enforced; stderr secrets are redacted before returning errors
pub async fn run(
&self,
node: &ActionNode,
inputs: &HashMap<Capability, serde_json::Value>,
creds: &NodeCredentials,
timeout_secs: u64,
) -> Result<HashMap<Capability, serde_json::Value>> {
let script_path = self.scripts_base.join(&node.handler);
let input_json = serde_json::to_string(inputs).context("serializing inputs to JSON")?;
let mut cmd = tokio::process::Command::new("nu");
cmd.arg("--no-config-file")
.arg(&script_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.envs(sanitize_env(creds.env_vars.iter()));
let mut child = cmd
.spawn()
.with_context(|| format!("spawning nu for node '{}'", node.id))?;
if let Some(stdin) = child.stdin.take() {
let mut writer = tokio::io::BufWriter::new(stdin);
writer
.write_all(input_json.as_bytes())
.await
.context("writing inputs to nu stdin")?;
writer.flush().await.context("flushing nu stdin")?;
}
let output =
tokio::time::timeout(Duration::from_secs(timeout_secs), child.wait_with_output())
.await
.map_err(|_| anyhow!("node '{}' timed out after {timeout_secs}s", node.id))??;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let redacted = redact_secrets(&stderr, &creds.env_vars);
return Err(anyhow!(
"node '{}' exited with {}: {redacted}",
node.id,
output.status
));
}
let raw: HashMap<String, serde_json::Value> = serde_json::from_slice(&output.stdout)
.with_context(|| {
format!(
"node '{}' stdout is not valid JSON — handler must emit a JSON object",
node.id
)
})?;
debug!("node '{}' produced {} capabilities", node.id, raw.len());
Ok(raw.into_iter().map(|(k, v)| (Capability(k), v)).collect())
}
/// Execute a compensation (rollback) script.
/// Failures are logged at WARN but do not propagate — compensation is best-effort.
pub async fn run_compensation(&self, node: &ActionNode, run_id: &PipelineRunId) -> Result<()> {
let Some(comp_rel) = &node.compensate else {
return Ok(());
};
let script_path = self.scripts_base.join(comp_rel);
let run_id_str = run_id.to_string();
let output = tokio::process::Command::new("nu")
.arg("--no-config-file")
.arg(&script_path)
.env("PIPELINE_RUN_ID", &run_id_str)
.output()
.await
.with_context(|| {
format!(
"spawning compensation script '{}' for node '{}'",
comp_rel.display(),
node.id
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(
"compensation '{}' for node '{}' failed (non-fatal): {}",
comp_rel.display(),
node.id,
stderr.trim()
);
}
Ok(())
}
}

View File

@ -0,0 +1,98 @@
use std::{collections::HashMap, sync::LazyLock};
use regex::Regex;
static SECRET_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
// Generic key=value patterns
Regex::new(r"(?i)(password|secret|token|key|credential)\s*=\s*\S+").unwrap(),
// Vault token pattern (hvs.*)
Regex::new(r"hvs\.[A-Za-z0-9_\-]{20,}").unwrap(),
// AWS-style secret
Regex::new(r"(?i)aws.{0,10}secret.{0,10}=\s*\S+").unwrap(),
]
});
/// Redact secrets from text before logging or returning in errors.
///
/// 1. Replaces literal values of any env var with length >= 8
/// 2. Replaces known secret patterns (key=value, Vault tokens)
pub fn redact_secrets(text: &str, env_values: &HashMap<String, String>) -> String {
let mut result = text.to_owned();
// Redact any env var value that appears literally in output
for value in env_values.values() {
if value.len() >= 8 {
result = result.replace(value.as_str(), "[REDACTED]");
}
}
// Redact regex patterns
for re in SECRET_PATTERNS.iter() {
result = re
.replace_all(&result, |caps: &regex::Captures| {
let full = &caps[0];
// Keep everything up to and including '=' or the pattern prefix,
// replace the sensitive value portion
if let Some(eq_pos) = full.find('=') {
format!("{}=[REDACTED]", &full[..eq_pos])
} else {
"[REDACTED]".to_owned()
}
})
.to_string();
}
result
}
/// Yield (key, value) pairs from env vars for use with Command::envs.
pub fn sanitize_env<'a>(
env: impl Iterator<Item = (&'a String, &'a String)>,
) -> impl Iterator<Item = (&'a str, &'a str)> {
env.map(|(k, v)| (k.as_str(), v.as_str()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_redact_env_var_values() {
let env = HashMap::from([
(
"API_TOKEN".to_string(),
"super-secret-token-12345".to_string(),
),
("SHORT".to_string(), "abc".to_string()), // too short, not redacted
]);
let text = "Running with token=super-secret-token-12345 and SHORT=abc";
let result = redact_secrets(text, &env);
assert!(!result.contains("super-secret-token-12345"));
assert!(result.contains("[REDACTED]"));
assert!(result.contains("SHORT=abc")); // short value not redacted
}
#[test]
fn test_redact_vault_token() {
let text = "auth failed: hvs.CAESIBfgabcdefghijklmnopqrstuvwxyz0123456789";
let result = redact_secrets(text, &HashMap::new());
assert!(!result.contains("hvs.CAE"));
assert!(result.contains("[REDACTED]"));
}
#[test]
fn test_redact_key_value_pattern() {
let text = "config: secret=mysupersecretvalue123";
let result = redact_secrets(text, &HashMap::new());
assert!(!result.contains("mysupersecretvalue123"));
assert!(result.contains("secret=[REDACTED]"));
}
#[test]
fn test_clean_text_unchanged() {
let text = "cargo build succeeded: 0 errors, 0 warnings";
let result = redact_secrets(text, &HashMap::new());
assert_eq!(result, text);
}
}

View File

@ -0,0 +1,66 @@
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use stratum_graph::{ActionGraph, ActionNode};
use tracing::{debug, warn};
/// Load a single ActionNode from a .ncl file by running `nickel export --format json`.
pub fn load_node_from_ncl(path: &Path) -> Result<ActionNode> {
let output = std::process::Command::new("nickel")
.arg("export")
.arg("--format")
.arg("json")
.arg(path)
.output()
.with_context(|| format!("running nickel export on '{}'", path.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"nickel export failed on '{}': {}",
path.display(),
stderr.trim()
));
}
serde_json::from_slice::<ActionNode>(&output.stdout).with_context(|| {
format!(
"parsing ActionNode JSON from '{}' — check that the .ncl file exports a valid NodeDefinition",
path.display()
)
})
}
/// Load all .ncl files from `dir` and build an ActionGraph.
/// Fails fast on the first Nickel typecheck or parse error — no partial graphs.
pub fn load_graph_from_dir(dir: &Path) -> Result<ActionGraph> {
let entries =
std::fs::read_dir(dir).with_context(|| format!("reading dir '{}'", dir.display()))?;
let mut nodes = Vec::new();
for entry in entries {
let entry = entry.context("reading dir entry")?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
continue;
}
debug!("loading node from '{}'", path.display());
match load_node_from_ncl(&path) {
Ok(node) => nodes.push(node),
Err(e) => {
return Err(e.context(format!("failed to load node from '{}'", path.display())));
}
}
}
if nodes.is_empty() {
warn!("no .ncl files found in '{}'", dir.display());
}
ActionGraph::from_nodes(nodes)
.with_context(|| format!("building ActionGraph from '{}'", dir.display()))
}

View File

@ -0,0 +1,5 @@
pub mod loader;
pub mod watcher;
pub use loader::{load_graph_from_dir, load_node_from_ncl};
pub use watcher::GraphWatcher;

View File

@ -0,0 +1,106 @@
use std::{path::PathBuf, sync::Arc};
use anyhow::{Context, Result};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::{mpsc, RwLock};
use tracing::{error, info, warn};
use stratum_graph::ActionGraph;
use super::loader::load_node_from_ncl;
pub struct GraphWatcher {
_watcher: RecommendedWatcher,
}
impl GraphWatcher {
/// Spawn a file watcher on `dir`. On any .ncl file modification/creation/removal,
/// reload the affected node and update the shared graph.
pub fn new(dir: PathBuf, graph: Arc<RwLock<ActionGraph>>) -> Result<Self> {
let (tx, mut rx) = mpsc::channel::<notify::Result<Event>>(64);
let mut watcher = notify::recommended_watcher(move |event| {
let _ = tx.blocking_send(event);
})
.context("creating filesystem watcher")?;
watcher
.watch(&dir, RecursiveMode::NonRecursive)
.with_context(|| format!("watching '{}'", dir.display()))?;
tokio::spawn(async move {
while let Some(event) = rx.recv().await {
match event {
Ok(e) => handle_event(e, &graph).await,
Err(e) => error!("watcher error: {e}"),
}
}
});
Ok(Self { _watcher: watcher })
}
}
async fn handle_event(event: Event, graph: &Arc<RwLock<ActionGraph>>) {
let paths: Vec<PathBuf> = event
.paths
.into_iter()
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("ncl"))
.collect();
if paths.is_empty() {
return;
}
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) => {
for path in paths {
match load_node_from_ncl(&path) {
Ok(node) => {
let node_id = node.id.clone();
let mut g = graph.write().await;
match g.apply_update(Some(node)) {
Ok(changed) => {
let changed_list = changed
.iter()
.map(|id| id.0.as_str())
.collect::<Vec<_>>()
.join(", ");
info!(
"hot-reload: updated node '{}' from '{}' (affected: [{}])",
node_id,
path.display(),
changed_list
);
}
Err(e) => {
error!(
"hot-reload: failed to apply update from '{}': {e}",
path.display()
);
}
}
}
Err(e) => {
warn!(
"hot-reload: failed to load '{}', keeping existing node: {e}",
path.display()
);
}
}
}
}
EventKind::Remove(_) => {
// File removed — remove node from graph
for path in paths {
// We can't parse the deleted file; log and skip for now.
// The orchestrator operator must remove nodes intentionally.
warn!(
"hot-reload: file '{}' removed — node will remain in graph until restart",
path.display()
);
}
}
_ => {}
}
}

View File

@ -0,0 +1,5 @@
pub mod auth;
pub mod context;
pub mod executor;
pub mod graph;
pub mod runner;

View File

@ -0,0 +1,262 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use anyhow::{anyhow, Result};
use stratum_graph::{ActionGraph, ActionNode, Capability, NodeId};
use stratum_state::{PipelineStatus, StateTracker, StepRecord};
use tokio::{sync::RwLock, task::JoinSet};
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use crate::{
auth::{CedarAuthorizer, NodeCredentials, VaultClient},
context::PipelineContext,
executor::NuExecutor,
};
use stratum_graph::types::BackoffStrategy;
pub struct StageRunner {
pub graph: Arc<RwLock<ActionGraph>>,
pub executor: Arc<NuExecutor>,
pub state: Arc<dyn StateTracker>,
pub auth: Arc<CedarAuthorizer>,
pub vault: Arc<VaultClient>,
pub cedar_required: bool,
}
impl StageRunner {
pub async fn run_pipeline(&self, ctx: Arc<PipelineContext>) -> Result<PipelineStatus> {
let stages = {
let g = self.graph.read().await;
g.plan(&ctx.trigger_subject, &ctx.trigger_payload)?
};
info!(
run_id = %ctx.run_id,
stages = stages.len(),
"pipeline started"
);
let mut executed_stages: Vec<Vec<NodeId>> = Vec::new();
let cancel = CancellationToken::new();
for (i, stage_nodes) in stages.iter().enumerate() {
info!(run_id = %ctx.run_id, stage = i, nodes = stage_nodes.len(), "executing stage");
match self.run_stage(stage_nodes, &ctx, cancel.clone()).await {
Ok(()) => {
executed_stages.push(stage_nodes.clone());
}
Err(e) => {
cancel.cancel();
error!(run_id = %ctx.run_id, stage = i, "stage failed: {e}");
self.state
.update_status(&ctx.run_id, PipelineStatus::Compensating)
.await?;
self.compensate(&executed_stages, &ctx).await?;
self.state
.update_status(&ctx.run_id, PipelineStatus::Compensated)
.await?;
info!(run_id = %ctx.run_id, "pipeline compensated");
return Ok(PipelineStatus::Compensated);
}
}
}
self.state
.update_status(&ctx.run_id, PipelineStatus::Success)
.await?;
info!(run_id = %ctx.run_id, "pipeline succeeded");
Ok(PipelineStatus::Success)
}
async fn run_stage(
&self,
nodes: &[NodeId],
ctx: &Arc<PipelineContext>,
cancel: CancellationToken,
) -> Result<()> {
let graph = self.graph.read().await;
let mut set: JoinSet<Result<()>> = JoinSet::new();
for node_id in nodes {
let node = graph
.nodes()
.get(node_id)
.ok_or_else(|| anyhow!("node '{node_id}' not found in graph"))?
.clone();
let ctx = Arc::clone(ctx);
let exec = Arc::clone(&self.executor);
let auth = Arc::clone(&self.auth);
let vault = Arc::clone(&self.vault);
let state = Arc::clone(&self.state);
let tok = cancel.clone();
let cedar_required = self.cedar_required;
set.spawn(async move {
tokio::select! {
result = run_node(node, ctx, exec, auth, vault, state, cedar_required) => result,
_ = tok.cancelled() => Err(anyhow!("node cancelled by sibling failure")),
}
});
}
// Drop read lock before awaiting tasks
drop(graph);
while let Some(res) = set.join_next().await {
match res.map_err(|e| anyhow!("join error: {e}"))? {
Ok(()) => {}
Err(e) => {
cancel.cancel();
set.abort_all();
return Err(e);
}
}
}
Ok(())
}
/// Saga compensation: reverse order through executed stages, each stage parallel.
/// Errors in compensation are logged but never propagate — best-effort.
async fn compensate(
&self,
executed_stages: &[Vec<NodeId>],
ctx: &Arc<PipelineContext>,
) -> Result<()> {
let graph = self.graph.read().await;
for stage in executed_stages.iter().rev() {
let mut set: JoinSet<Result<()>> = JoinSet::new();
for node_id in stage {
let Some(node) = graph.nodes().get(node_id).cloned() else {
continue;
};
if node.compensate.is_none() {
continue;
}
let exec = Arc::clone(&self.executor);
let run_id = ctx.run_id.clone();
set.spawn(async move { exec.run_compensation(&node, &run_id).await });
}
while let Some(res) = set.join_next().await {
if let Err(e) = res.map_err(|e| anyhow!("join: {e}"))? {
error!("compensation error (non-fatal): {e}");
}
}
}
Ok(())
}
}
async fn run_node(
node: ActionNode,
ctx: Arc<PipelineContext>,
exec: Arc<NuExecutor>,
auth: Arc<CedarAuthorizer>,
vault: Arc<VaultClient>,
state: Arc<dyn StateTracker>,
cedar_required: bool,
) -> Result<()> {
// 1. Cedar authorization
if cedar_required {
auth.authorize(
"orchestrator",
"execute",
&format!("Node::\"{}\"", node.id.0),
)?;
}
// 2. Extract inputs from PipelineContext
let input_caps: Vec<Capability> = node.input_schemas.keys().cloned().collect();
let inputs = ctx.extract_inputs(&input_caps).await?;
// 3. Fetch Vault credentials
let creds = vault
.get_node_credentials(&node.id, node.timeout_secs)
.await?;
// 4. Record step start
let step_start = StepRecord::start(node.id.clone());
state.record_step(&ctx.run_id, &step_start).await?;
// 5. Execute with retry
let result = execute_with_retry(&node, &inputs, &creds, &exec).await;
match result {
Ok(outputs) => {
let deposited: Vec<Capability> = outputs.keys().cloned().collect();
for (cap, value) in outputs {
ctx.deposit(&cap, value).await?;
}
state
.record_step(&ctx.run_id, &step_start.succeed(deposited))
.await?;
Ok(())
}
Err(e) => {
// Revoke credentials before propagating error
if let Err(rev_err) = vault.revoke_credentials(&creds.lease_id).await {
warn!(
"failed to revoke credentials for node '{}': {rev_err}",
node.id
);
}
let err_str = e.to_string();
state
.record_step(&ctx.run_id, &step_start.fail(err_str.clone()))
.await?;
Err(anyhow!("node '{}' failed: {err_str}", node.id))
}
}
}
async fn execute_with_retry(
node: &ActionNode,
inputs: &HashMap<Capability, serde_json::Value>,
creds: &NodeCredentials,
exec: &NuExecutor,
) -> Result<HashMap<Capability, serde_json::Value>> {
let mut last_err = None;
for attempt in 0..=node.retry.max {
match exec.run(node, inputs, creds, node.timeout_secs).await {
Ok(outputs) => return Ok(outputs),
Err(e) if attempt < node.retry.max => {
let delay = backoff_delay(&node.retry, attempt);
warn!(
"node '{}' attempt {}/{} failed, retrying in {:?}: {e}",
node.id,
attempt + 1,
node.retry.max + 1,
delay
);
last_err = Some(e);
tokio::time::sleep(delay).await;
}
Err(e) => return Err(e),
}
}
Err(last_err.unwrap_or_else(|| anyhow!("retry exhausted with no error — should not happen")))
}
fn backoff_delay(policy: &stratum_graph::RetryPolicy, attempt: u32) -> Duration {
let base = Duration::from_secs(policy.backoff_secs);
match policy.strategy {
BackoffStrategy::Fixed => base,
BackoffStrategy::Linear => base * (attempt + 1),
BackoffStrategy::Exponential => base * 2u32.saturating_pow(attempt),
}
}

View File

@ -0,0 +1,146 @@
/// Integration test: Cedar authorization — multi-policy permit/deny scenarios.
use std::io::Write;
use stratum_orchestrator::auth::CedarAuthorizer;
use tempfile::TempDir;
fn write_policy(dir: &TempDir, name: &str, content: &str) {
let path = dir.path().join(name);
let mut f = std::fs::File::create(path).unwrap();
f.write_all(content.as_bytes()).unwrap();
}
#[test]
fn test_permit_orchestrator_executes_allowed_node() {
let dir = TempDir::new().unwrap();
write_policy(
&dir,
"allow.cedar",
r#"permit(
principal == User::"orchestrator",
action == Action::"execute",
resource == Node::"lint"
);"#,
);
let authz = CedarAuthorizer::load_from_dir(dir.path()).unwrap();
authz
.authorize("orchestrator", "execute", "Node::\"lint\"")
.unwrap();
}
#[test]
fn test_deny_unknown_principal() {
let dir = TempDir::new().unwrap();
write_policy(
&dir,
"allow.cedar",
r#"permit(
principal == User::"orchestrator",
action == Action::"execute",
resource == Node::"lint"
);"#,
);
let authz = CedarAuthorizer::load_from_dir(dir.path()).unwrap();
// Unknown principal — no matching permit → implicit deny
let err = authz
.authorize("rogue-agent", "execute", "Node::\"lint\"")
.unwrap_err();
assert!(err.to_string().contains("Cedar denied"));
}
#[test]
fn test_deny_wrong_resource() {
let dir = TempDir::new().unwrap();
write_policy(
&dir,
"allow.cedar",
r#"permit(
principal == User::"orchestrator",
action == Action::"execute",
resource == Node::"lint"
);"#,
);
let authz = CedarAuthorizer::load_from_dir(dir.path()).unwrap();
let err = authz
.authorize("orchestrator", "execute", "Node::\"build\"")
.unwrap_err();
assert!(err.to_string().contains("Cedar denied"));
}
#[test]
fn test_explicit_forbid_overrides_permit() {
let dir = TempDir::new().unwrap();
write_policy(
&dir,
"00_permit.cedar",
r#"permit(
principal == User::"orchestrator",
action == Action::"execute",
resource == Node::"lint"
);"#,
);
write_policy(
&dir,
"01_forbid.cedar",
r#"forbid(principal, action, resource);"#,
);
let authz = CedarAuthorizer::load_from_dir(dir.path()).unwrap();
// Cedar: forbid overrides permit
let err = authz
.authorize("orchestrator", "execute", "Node::\"lint\"")
.unwrap_err();
assert!(err.to_string().contains("Cedar denied"));
}
#[test]
fn test_empty_policy_dir_returns_error() {
let dir = TempDir::new().unwrap();
let err = match CedarAuthorizer::load_from_dir(dir.path()) {
Err(e) => e,
Ok(_) => panic!("expected load_from_dir to fail on empty directory"),
};
assert!(
err.to_string().contains("no .cedar policy files"),
"expected 'no .cedar policy files' error, got: {err}"
);
}
#[test]
fn test_multiple_permits_any_matching_allows() {
let dir = TempDir::new().unwrap();
write_policy(
&dir,
"nodes.cedar",
r#"permit(
principal == User::"orchestrator",
action == Action::"execute",
resource == Node::"lint"
);
permit(
principal == User::"orchestrator",
action == Action::"execute",
resource == Node::"build"
);
permit(
principal == User::"orchestrator",
action == Action::"execute",
resource == Node::"install"
);"#,
);
let authz = CedarAuthorizer::load_from_dir(dir.path()).unwrap();
for node in ["lint", "build", "install"] {
authz
.authorize("orchestrator", "execute", &format!("Node::\"{node}\""))
.unwrap_or_else(|e| panic!("node '{node}' should be permitted: {e}"));
}
// Node not in the permit list → deny
authz
.authorize("orchestrator", "execute", "Node::\"notify\"")
.unwrap_err();
}

View File

@ -0,0 +1,172 @@
/// Integration test: ActionGraph + PipelineContext + StateTracker working together.
///
/// Simulates a two-stage pipeline data plane (lint → build) without requiring
/// Vault, Nu executors, or external services.
use std::{path::PathBuf, sync::Arc};
use stratum_graph::{
types::{ActionNode, BackoffStrategy, Capability, NodeId, RetryPolicy},
ActionGraph,
};
use stratum_orchestrator::context::PipelineContext;
use stratum_state::{InMemoryStateTracker, PipelineStatus, StateTracker};
fn cap(s: &str) -> Capability {
Capability(s.to_owned())
}
fn node(id: &str, inputs: &[&str], outputs: &[&str], triggers: &[&str]) -> ActionNode {
ActionNode {
id: NodeId(id.to_owned()),
handler: PathBuf::from(format!("scripts/nu/{id}.nu")),
input_schemas: inputs
.iter()
.map(|c| (cap(c), PathBuf::from(format!("schemas/{c}.ncl"))))
.collect(),
output_schemas: outputs
.iter()
.map(|c| (cap(c), PathBuf::from(format!("schemas/{c}.ncl"))))
.collect(),
compensate: None,
retry: RetryPolicy {
max: 0,
backoff_secs: 1,
strategy: BackoffStrategy::Fixed,
},
timeout_secs: 30,
atomic: false,
triggers: triggers.iter().map(|t| t.to_string()).collect(),
}
}
async fn make_ctx() -> (PipelineContext, Arc<InMemoryStateTracker>) {
let state = Arc::new(InMemoryStateTracker::new());
let ctx = PipelineContext::new(
"dev.crate.foo.modified".to_string(),
serde_json::json!({"crate": "foo"}),
Arc::clone(&state) as Arc<dyn StateTracker>,
PathBuf::from("/tmp/no-schemas"),
)
.await
.unwrap();
(ctx, state)
}
#[tokio::test]
async fn test_two_stage_pipeline_data_plane() {
let (ctx, state) = make_ctx().await;
// Build the graph: lint (stage 0) → build (stage 1)
let nodes = vec![
node("lint", &[], &["linted"], &["dev.crate.>", "dev.>", "dev.crate.foo.modified"]),
node("build", &["linted"], &["built"], &[]),
];
let graph = ActionGraph::from_nodes(nodes).unwrap();
// Plan the graph for our trigger subject
let stages = graph
.plan("dev.crate.foo.modified", &ctx.trigger_payload)
.unwrap();
assert_eq!(stages.len(), 2, "expected two stages");
assert_eq!(stages[0], vec![NodeId("lint".into())]);
assert_eq!(stages[1], vec![NodeId("build".into())]);
// ── Stage 0: lint ──────────────────────────────────────────────────────
// lint has no inputs; deposits "linted" capability
let linted_value = serde_json::json!({"warnings": 0, "errors": 0});
ctx.deposit(&cap("linted"), linted_value.clone())
.await
.unwrap();
// ── Stage 1: build ─────────────────────────────────────────────────────
// build reads "linted" produced by stage 0
let inputs = ctx.extract_inputs(&[cap("linted")]).await.unwrap();
assert_eq!(inputs[&cap("linted")], linted_value);
let built_value = serde_json::json!({"artifact": "target/release/foo", "size_bytes": 4096});
ctx.deposit(&cap("built"), built_value.clone()).await.unwrap();
// ── Final state assertions ─────────────────────────────────────────────
state
.update_status(&ctx.run_id, PipelineStatus::Success)
.await
.unwrap();
let run = state.get_run(&ctx.run_id).await.unwrap().unwrap();
assert_eq!(run.status, PipelineStatus::Success);
assert_eq!(run.trigger_subject, "dev.crate.foo.modified");
// Both capabilities must be retrievable
let loaded_linted = state
.load_capability(&ctx.run_id, &cap("linted"))
.await
.unwrap();
let loaded_built = state
.load_capability(&ctx.run_id, &cap("built"))
.await
.unwrap();
assert_eq!(loaded_linted, Some(linted_value));
assert_eq!(loaded_built, Some(built_value));
}
#[tokio::test]
async fn test_graph_plan_wildcard_subject_routing() {
let nodes = vec![
node("lint", &[], &["linted"], &["dev.crate.>"]),
node("build", &["linted"], &["built"], &[]),
];
let graph = ActionGraph::from_nodes(nodes).unwrap();
// Any subject matching `dev.crate.>` should produce a plan
for subject in ["dev.crate.foo.modified", "dev.crate.bar.changed", "dev.crate.x"] {
let stages = graph.plan(subject, &serde_json::Value::Null).unwrap();
assert!(!stages.is_empty(), "no plan for subject '{subject}'");
assert_eq!(stages[0][0], NodeId("lint".into()));
}
// Subject not matching should return an error
let err = graph
.plan("prod.deploy.triggered", &serde_json::Value::Null)
.unwrap_err();
assert!(err.to_string().contains("no entry nodes match"));
}
#[tokio::test]
async fn test_multiple_contexts_share_state() {
// Two independent pipelines on the same StateTracker must not interfere.
let state = Arc::new(InMemoryStateTracker::new());
let ctx_a = PipelineContext::new(
"dev.crate.a.modified".to_string(),
serde_json::Value::Null,
Arc::clone(&state) as Arc<dyn StateTracker>,
PathBuf::from("/tmp/no-schemas"),
)
.await
.unwrap();
let ctx_b = PipelineContext::new(
"dev.crate.b.modified".to_string(),
serde_json::Value::Null,
Arc::clone(&state) as Arc<dyn StateTracker>,
PathBuf::from("/tmp/no-schemas"),
)
.await
.unwrap();
let linted_a = serde_json::json!({"crate": "a", "warnings": 0});
let linted_b = serde_json::json!({"crate": "b", "warnings": 2});
ctx_a.deposit(&cap("linted"), linted_a.clone()).await.unwrap();
ctx_b.deposit(&cap("linted"), linted_b.clone()).await.unwrap();
// Each context sees its own value, not the other's
assert_eq!(ctx_a.extract(&cap("linted")).await.unwrap(), linted_a);
assert_eq!(ctx_b.extract(&cap("linted")).await.unwrap(), linted_b);
// Verify via StateTracker too
let val_a = state.load_capability(&ctx_a.run_id, &cap("linted")).await.unwrap();
let val_b = state.load_capability(&ctx_b.run_id, &cap("linted")).await.unwrap();
assert_eq!(val_a, Some(linted_a));
assert_eq!(val_b, Some(linted_b));
}

View File

@ -0,0 +1,149 @@
/// Integration test: Saga compensation state machine.
///
/// Validates the state transition sequence:
/// Running → (stage 1 succeeds) → Compensating → Compensated
/// and that capabilities deposited in stage 1 remain accessible during compensation.
use std::{path::PathBuf, sync::Arc};
use stratum_graph::types::Capability;
use stratum_orchestrator::context::PipelineContext;
use stratum_state::{InMemoryStateTracker, PipelineStatus, StateTracker, StepRecord};
use stratum_graph::types::NodeId;
fn cap(s: &str) -> Capability {
Capability(s.to_owned())
}
async fn make_ctx() -> (PipelineContext, Arc<InMemoryStateTracker>) {
let state = Arc::new(InMemoryStateTracker::new());
let ctx = PipelineContext::new(
"dev.crate.foo.modified".to_string(),
serde_json::Value::Null,
Arc::clone(&state) as Arc<dyn StateTracker>,
PathBuf::from("/tmp/no-schemas"),
)
.await
.unwrap();
(ctx, state)
}
#[tokio::test]
async fn test_compensation_state_transitions() {
let (ctx, state) = make_ctx().await;
// Stage 0 (lint) succeeds — deposit capability and record step
let lint_node = NodeId("lint".into());
let step_start = StepRecord::start(lint_node.clone());
state.record_step(&ctx.run_id, &step_start).await.unwrap();
let linted_value = serde_json::json!({"warnings": 0});
ctx.deposit(&cap("linted"), linted_value.clone())
.await
.unwrap();
let step_done = step_start.succeed(vec![cap("linted")]);
state.record_step(&ctx.run_id, &step_done).await.unwrap();
// Stage 1 (build) fails — transition to Compensating
state
.update_status(&ctx.run_id, PipelineStatus::Compensating)
.await
.unwrap();
let run = state.get_run(&ctx.run_id).await.unwrap().unwrap();
assert_eq!(run.status, PipelineStatus::Compensating);
// Compensation: lint's rollback reads the "linted" capability from DB
// (in production NuExecutor would call the compensation script;
// here we verify the data is accessible)
let linted_in_db = state
.load_capability(&ctx.run_id, &cap("linted"))
.await
.unwrap();
assert_eq!(
linted_in_db,
Some(linted_value),
"linted capability must survive into compensation phase"
);
// Compensation completes
state
.update_status(&ctx.run_id, PipelineStatus::Compensated)
.await
.unwrap();
let run = state.get_run(&ctx.run_id).await.unwrap().unwrap();
assert_eq!(run.status, PipelineStatus::Compensated);
// Step record must show lint succeeded before compensation started
assert_eq!(run.steps.len(), 1, "exactly one step record (lint)");
assert_eq!(run.steps[0].node_id, lint_node);
assert_eq!(run.steps[0].capabilities_deposited, vec![cap("linted")]);
}
#[tokio::test]
async fn test_step_upsert_within_saga() {
let (ctx, state) = make_ctx().await;
let node_id = NodeId("fmt".into());
// Record start, then update to success (upsert must not duplicate)
let start = StepRecord::start(node_id.clone());
state.record_step(&ctx.run_id, &start).await.unwrap();
let done = start.succeed(vec![cap("formatted")]);
state.record_step(&ctx.run_id, &done).await.unwrap();
let run = state.get_run(&ctx.run_id).await.unwrap().unwrap();
assert_eq!(run.steps.len(), 1, "upsert must not duplicate step records");
assert_eq!(
run.steps[0].capabilities_deposited,
vec![cap("formatted")],
"step must reflect the succeeded record"
);
}
#[tokio::test]
async fn test_multiple_stages_compensated_in_order() {
let (ctx, state) = make_ctx().await;
// Simulate two successful stages before failure
for (name, cap_name) in [("lint", "linted"), ("fmt", "formatted")] {
let step = StepRecord::start(NodeId(name.into()));
state.record_step(&ctx.run_id, &step).await.unwrap();
ctx.deposit(&cap(cap_name), serde_json::json!({"ok": true}))
.await
.unwrap();
let done = step.succeed(vec![cap(cap_name)]);
state.record_step(&ctx.run_id, &done).await.unwrap();
}
// Third stage fails → compensate
state
.update_status(&ctx.run_id, PipelineStatus::Compensating)
.await
.unwrap();
// Both capabilities from stages 0 and 1 must remain accessible
for cap_name in ["linted", "formatted"] {
let v = state
.load_capability(&ctx.run_id, &cap(cap_name))
.await
.unwrap();
assert!(
v.is_some(),
"capability '{cap_name}' must be accessible during compensation"
);
}
state
.update_status(&ctx.run_id, PipelineStatus::Compensated)
.await
.unwrap();
let run = state.get_run(&ctx.run_id).await.unwrap().unwrap();
assert_eq!(run.status, PipelineStatus::Compensated);
assert_eq!(run.steps.len(), 2, "two step records from two stages");
}

View File

@ -0,0 +1,29 @@
[package]
name = "stratum-state"
version = "0.1.0"
edition.workspace = true
description = "Operational domain: pipeline run state, step records, capability store"
license.workspace = true
[dependencies]
stratum-graph = { path = "../stratum-graph" }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
tracing = { workspace = true }
surrealdb = { workspace = true, optional = true }
[features]
default = ["mem-store"]
mem-store = ["surrealdb", "surrealdb/kv-mem"]
rocksdb-store = ["surrealdb", "surrealdb/kv-rocksdb"]
[dev-dependencies]
tokio-test = { workspace = true }
tempfile = { workspace = true }

View File

@ -0,0 +1,8 @@
pub mod tracker;
pub mod types;
pub use tracker::{InMemoryStateTracker, StateTracker};
pub use types::{PipelineRun, PipelineRunId, PipelineStatus, StepRecord, StepStatus};
#[cfg(any(feature = "mem-store", feature = "rocksdb-store"))]
pub use tracker::SurrealStateTracker;

View File

@ -0,0 +1,343 @@
use anyhow::Result;
use async_trait::async_trait;
use stratum_graph::types::Capability;
use crate::types::{PipelineRun, PipelineRunId, PipelineStatus, StepRecord};
#[async_trait]
pub trait StateTracker: Send + Sync {
async fn create_run(&self, run: &PipelineRun) -> Result<()>;
async fn update_status(&self, id: &PipelineRunId, status: PipelineStatus) -> Result<()>;
async fn record_step(&self, id: &PipelineRunId, step: &StepRecord) -> Result<()>;
async fn deposit_capability(
&self,
id: &PipelineRunId,
cap: &Capability,
value: &serde_json::Value,
) -> Result<()>;
async fn load_capability(
&self,
id: &PipelineRunId,
cap: &Capability,
) -> Result<Option<serde_json::Value>>;
async fn get_run(&self, id: &PipelineRunId) -> Result<Option<PipelineRun>>;
}
/// In-memory implementation — for tests and ephemeral pipelines.
pub struct InMemoryStateTracker {
runs: tokio::sync::RwLock<std::collections::HashMap<String, PipelineRun>>,
caps: tokio::sync::RwLock<std::collections::HashMap<String, serde_json::Value>>,
}
impl InMemoryStateTracker {
pub fn new() -> Self {
Self {
runs: tokio::sync::RwLock::new(std::collections::HashMap::new()),
caps: tokio::sync::RwLock::new(std::collections::HashMap::new()),
}
}
fn cap_key(id: &PipelineRunId, cap: &Capability) -> String {
format!("{}:{}", id.0, cap.0)
}
}
impl Default for InMemoryStateTracker {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl StateTracker for InMemoryStateTracker {
async fn create_run(&self, run: &PipelineRun) -> Result<()> {
self.runs
.write()
.await
.insert(run.id.0.to_string(), run.clone());
Ok(())
}
async fn update_status(&self, id: &PipelineRunId, status: PipelineStatus) -> Result<()> {
if let Some(run) = self.runs.write().await.get_mut(&id.0.to_string()) {
run.status = status;
}
Ok(())
}
async fn record_step(&self, id: &PipelineRunId, step: &StepRecord) -> Result<()> {
if let Some(run) = self.runs.write().await.get_mut(&id.0.to_string()) {
// Replace existing step record for this node if present
if let Some(pos) = run.steps.iter().position(|s| s.node_id == step.node_id) {
run.steps[pos] = step.clone();
} else {
run.steps.push(step.clone());
}
}
Ok(())
}
async fn deposit_capability(
&self,
id: &PipelineRunId,
cap: &Capability,
value: &serde_json::Value,
) -> Result<()> {
self.caps
.write()
.await
.insert(Self::cap_key(id, cap), value.clone());
Ok(())
}
async fn load_capability(
&self,
id: &PipelineRunId,
cap: &Capability,
) -> Result<Option<serde_json::Value>> {
Ok(self.caps.read().await.get(&Self::cap_key(id, cap)).cloned())
}
async fn get_run(&self, id: &PipelineRunId) -> Result<Option<PipelineRun>> {
Ok(self.runs.read().await.get(&id.0.to_string()).cloned())
}
}
/// SurrealDB-backed implementation — persistent, survives restarts.
#[cfg(any(feature = "mem-store", feature = "rocksdb-store"))]
pub use surreal_tracker::SurrealStateTracker;
#[cfg(any(feature = "mem-store", feature = "rocksdb-store"))]
mod surreal_tracker {
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::Serialize;
#[cfg(feature = "mem-store")]
use surrealdb::engine::local::Mem;
use surrealdb::{engine::local::Db, Surreal};
use stratum_graph::types::Capability;
use crate::types::{PipelineRun, PipelineRunId, PipelineStatus, StepRecord};
use super::StateTracker;
/// `Db` is the `Connection` implementor for all embedded SurrealDB engines.
/// `Mem` / `RocksDb` are engine *tokens* passed to `Surreal::new::<Engine>()`.
pub struct SurrealStateTracker {
db: Surreal<Db>,
}
impl SurrealStateTracker {
/// Embedded in-memory instance — test / ephemeral use.
#[cfg(feature = "mem-store")]
pub async fn new_mem() -> Result<Self> {
let db = Surreal::new::<Mem>(())
.await
.context("creating in-memory SurrealDB")?;
db.use_ns("stratum").use_db("state").await?;
Ok(Self { db })
}
/// Embedded RocksDB instance — persistent across restarts.
#[cfg(feature = "rocksdb-store")]
pub async fn new_rocksdb(path: impl AsRef<std::path::Path>) -> Result<Self> {
use surrealdb::engine::local::RocksDb;
let db = Surreal::new::<RocksDb>(path.as_ref())
.await
.context("creating RocksDB SurrealDB")?;
db.use_ns("stratum").use_db("state").await?;
Ok(Self { db })
}
}
/// Route all DB I/O through `serde_json::Value`, which implements SurrealDB 3's
/// `SurrealValue` trait. Custom types don't need to implement `SurrealValue`.
fn to_json<T: Serialize>(v: &T) -> Result<serde_json::Value> {
serde_json::to_value(v).context("serializing to serde_json::Value")
}
fn from_json<T: serde::de::DeserializeOwned>(v: serde_json::Value) -> Result<T> {
serde_json::from_value(v).context("deserializing from serde_json::Value")
}
#[async_trait]
impl StateTracker for SurrealStateTracker {
async fn create_run(&self, run: &PipelineRun) -> Result<()> {
let id = run.id.0.to_string();
let payload = to_json(run)?;
let _: Option<serde_json::Value> = self
.db
.upsert(("pipeline_run_v1", id))
.content(payload)
.await
.context("create_run")?;
Ok(())
}
async fn update_status(&self, id: &PipelineRunId, status: PipelineStatus) -> Result<()> {
let id_str = id.0.to_string();
let patch = to_json(&serde_json::json!({ "status": status }))?;
let _: Option<serde_json::Value> = self
.db
.update(("pipeline_run_v1", id_str))
.merge(patch)
.await
.context("update_status")?;
Ok(())
}
async fn record_step(&self, id: &PipelineRunId, step: &StepRecord) -> Result<()> {
let id_str = id.0.to_string();
let raw: Option<serde_json::Value> = self
.db
.select(("pipeline_run_v1", id_str.clone()))
.await
.context("record_step: get run")?;
if let Some(raw) = raw {
let mut run: PipelineRun = from_json(raw)?;
if let Some(pos) = run.steps.iter().position(|s| s.node_id == step.node_id) {
run.steps[pos] = step.clone();
} else {
run.steps.push(step.clone());
}
let payload = to_json(&run)?;
let _: Option<serde_json::Value> = self
.db
.upsert(("pipeline_run_v1", id_str))
.content(payload)
.await
.context("record_step: update run")?;
}
Ok(())
}
async fn deposit_capability(
&self,
id: &PipelineRunId,
cap: &Capability,
value: &serde_json::Value,
) -> Result<()> {
let record_key = format!("{}-{}", id.0, cap.0.replace(' ', "_"));
let payload = serde_json::json!({
"run_id": id.0.to_string(),
"capability": cap.0.clone(),
"value": value,
});
let _: Option<serde_json::Value> = self
.db
.upsert(("capability_store_v1", record_key))
.content(payload)
.await
.context("deposit_capability")?;
Ok(())
}
async fn load_capability(
&self,
id: &PipelineRunId,
cap: &Capability,
) -> Result<Option<serde_json::Value>> {
let record_key = format!("{}-{}", id.0, cap.0.replace(' ', "_"));
let raw: Option<serde_json::Value> = self
.db
.select(("capability_store_v1", record_key))
.await
.context("load_capability")?;
Ok(raw.and_then(|mut r| r.get_mut("value").map(|v| v.take())))
}
async fn get_run(&self, id: &PipelineRunId) -> Result<Option<PipelineRun>> {
let id_str = id.0.to_string();
let raw: Option<serde_json::Value> = self
.db
.select(("pipeline_run_v1", id_str))
.await
.context("get_run")?;
raw.map(from_json).transpose()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{PipelineRun, StepRecord};
use stratum_graph::types::{Capability, NodeId};
#[tokio::test]
async fn test_create_and_get_run() {
let tracker = InMemoryStateTracker::new();
let run = PipelineRun::new(
"dev.crate.foo.modified".to_string(),
serde_json::Value::Null,
);
let id = run.id.clone();
tracker.create_run(&run).await.unwrap();
let fetched = tracker.get_run(&id).await.unwrap();
assert!(fetched.is_some());
assert_eq!(fetched.unwrap().trigger_subject, "dev.crate.foo.modified");
}
#[tokio::test]
async fn test_deposit_and_load_capability() {
let tracker = InMemoryStateTracker::new();
let run = PipelineRun::new("test".to_string(), serde_json::Value::Null);
let id = run.id.clone();
tracker.create_run(&run).await.unwrap();
let cap = Capability("linted".to_string());
let value = serde_json::json!({"status": "clean", "warnings": 0});
tracker.deposit_capability(&id, &cap, &value).await.unwrap();
let loaded = tracker.load_capability(&id, &cap).await.unwrap();
assert_eq!(loaded, Some(value));
}
#[tokio::test]
async fn test_status_transitions() {
let tracker = InMemoryStateTracker::new();
let run = PipelineRun::new("test".to_string(), serde_json::Value::Null);
let id = run.id.clone();
tracker.create_run(&run).await.unwrap();
tracker
.update_status(&id, PipelineStatus::AwaitingAgent)
.await
.unwrap();
tracker
.update_status(&id, PipelineStatus::Running)
.await
.unwrap();
tracker
.update_status(&id, PipelineStatus::Success)
.await
.unwrap();
let run = tracker.get_run(&id).await.unwrap().unwrap();
assert_eq!(run.status, PipelineStatus::Success);
}
#[tokio::test]
async fn test_record_step_upsert() {
let tracker = InMemoryStateTracker::new();
let run = PipelineRun::new("test".to_string(), serde_json::Value::Null);
let id = run.id.clone();
tracker.create_run(&run).await.unwrap();
let node_id = NodeId("lint".to_string());
let step = StepRecord::start(node_id.clone());
tracker.record_step(&id, &step).await.unwrap();
let step_done = step.succeed(vec![Capability("linted".to_string())]);
tracker.record_step(&id, &step_done).await.unwrap();
let run = tracker.get_run(&id).await.unwrap().unwrap();
assert_eq!(run.steps.len(), 1); // upserted, not duplicated
assert_eq!(run.steps[0].capabilities_deposited.len(), 1);
}
}

View File

@ -0,0 +1,108 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use stratum_graph::types::{Capability, NodeId};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PipelineRunId(pub Uuid);
impl PipelineRunId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
impl Default for PipelineRunId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for PipelineRunId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PipelineStatus {
Running,
AwaitingAgent,
Success,
Failed,
Compensating,
Compensated,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
Running,
Success,
Failed,
Compensated,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineRun {
pub id: PipelineRunId,
pub trigger_subject: String,
pub trigger_payload: serde_json::Value,
pub status: PipelineStatus,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub steps: Vec<StepRecord>,
}
impl PipelineRun {
pub fn new(trigger_subject: String, trigger_payload: serde_json::Value) -> Self {
Self {
id: PipelineRunId::new(),
trigger_subject,
trigger_payload,
status: PipelineStatus::Running,
started_at: Utc::now(),
finished_at: None,
steps: vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepRecord {
pub node_id: NodeId,
pub status: StepStatus,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub capabilities_deposited: Vec<Capability>,
pub error: Option<String>,
}
impl StepRecord {
pub fn start(node_id: NodeId) -> Self {
Self {
node_id,
status: StepStatus::Running,
started_at: Utc::now(),
finished_at: None,
capabilities_deposited: vec![],
error: None,
}
}
pub fn succeed(mut self, deposited: Vec<Capability>) -> Self {
self.status = StepStatus::Success;
self.finished_at = Some(Utc::now());
self.capabilities_deposited = deposited;
self
}
pub fn fail(mut self, error: String) -> Self {
self.status = StepStatus::Failed;
self.finished_at = Some(Utc::now());
self.error = Some(error);
self
}
}

43
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,43 @@
version: "3.9"
# Stratum dev stack — SurrealDB + Zot OCI registry
# NATS is assumed running via provisioning docker-compose on the same host network.
# Start: docker-compose -f docker-compose.dev.yml up -d
networks:
stratum-dev:
driver: bridge
services:
surrealdb-stratum:
image: surrealdb/surrealdb:v2
command: start --log info --user root --pass root memory
ports:
- "8100:8000"
networks:
- stratum-dev
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
zot-stratum:
image: ghcr.io/project-zot/zot-linux-amd64:latest
ports:
- "5000:5000"
volumes:
- ./config/zot-config.json:/etc/zot/config.json:ro
- zot-data:/var/lib/registry
networks:
- stratum-dev
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:5000/v2/"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
volumes:
zot-data:

View File

@ -10,6 +10,7 @@ Documented architectural decisions following the ADR format:
- [**ADR-001: Stratum-Embeddings**](adrs/001-stratum-embeddings.md) - Unified embedding library
- [**ADR-002: Stratum-LLM**](adrs/002-stratum-llm.md) - Unified LLM provider library
- [**ADR-003: Stratum-Orchestrator**](adrs/003-stratum-orchestrator.md) - Graph-driven workflow orchestrator
## ADR Format

View File

@ -0,0 +1,179 @@
# ADR-003: Stratum-Orchestrator — Graph-Driven Workflow Orchestrator
**Status**: Accepted
**Date**: 2026-02-20
## Context
The StratumIOps ecosystem spans multiple active projects (provisioning, kogral, syntaxis, typedialog) that evolve concurrently with cross-project dependencies. Each project triggers build, validation, publish, and notification workflows in response to code changes. Before this decision, these workflows were:
- Hardcoded per-project scripts with no shared execution model
- Not auditable — no durable record of which step ran, in what order, with what outcome
- Not composable — a workflow in `provisioning` could not react to an event from `kogral`
- Not safe — no atomicity, no rollback on partial failure, no credential scoping
- Not scalable — adding a new project meant copying and adapting scripts, accumulating drift
The core requirement is a **cross-project, event-driven, agnostic workflow orchestrator** that can coordinate build pipelines, AI agent tasks, and infrastructure operations without being modified for each new project or provider.
Two design directions were evaluated:
| Approach | Description | Problem |
|----------|-------------|---------|
| **Static rule router** | Map NATS subject patterns to scripts in a config file | Rules multiply with projects; changing a workflow requires editing the router |
| **Graph-driven engine** | Events traverse a DAG of action nodes declared in Nickel; orchestrator is agnostic | Orchestrator never changes for new workflows |
The graph-driven approach was chosen. The orchestrator loads node definitions from Nickel files, builds an in-memory `ActionGraph`, and executes pipelines by traversing the graph — it does not know what the pipeline does, only how to coordinate it.
## Fundamental Design Characteristics
### 1. Graph-Driven Execution, Not Static Routing
The orchestrator does not contain routing tables. Instead, NATS subject patterns are matched against an `ActionGraph` built from Nickel node definitions. Each `ActionNode` declares:
- `trigger`: NATS subject patterns that can activate this node as an entry point
- `input_schemas`: capabilities required before execution
- `output_schemas`: capabilities produced after execution
- `compensate`: optional rollback script for Saga atomicity
The graph is built by traversal of producer/consumer capability indexes. Topological sort produces a staged execution plan. **Adding a new workflow means adding `.ncl` files — the orchestrator binary never changes.**
### 2. Stateless Orchestrator — DB-First State
The orchestrator process holds no durable state. Every `PipelineContext` write goes to SurrealDB first, then updates an in-memory `DashMap` cache. On crash, a restarted instance reconstructs the cache from the DB. This enables:
- **Horizontal scaling**: multiple orchestrator instances share one SurrealDB, no split-brain
- **Crash recovery**: pipelines resume from last persisted capability, not from the beginning
- **Observability**: full pipeline state is queryable at any point without instrumenting the process
### 3. Nickel as Single Source of Truth — No Dual Truth
Action nodes, capability schemas, and the orchestrator startup config are all defined in Nickel. There is no separate indexing process, no database copy of node definitions used at runtime. The `ActionGraph` is built in-memory from `.ncl` files at startup via `nickel export --format json`, then kept live via a `notify` file watcher for hot-reload.
This eliminates the dual-truth problem: the Nickel file IS the definition. No risk of a database record diverging from the file on disk.
### 4. Capability Model — Dependency Inversion at Execution Level
Nodes do not depend on each other directly. They declare capabilities they produce and consume. The graph engine resolves dependencies:
```
lint-crate → produces: linted-code
fmt-crate → produces: formatted-code
build-crate → consumes: linted-code, formatted-code
→ produces: built-artifact
install-crate → consumes: built-artifact
```
This is Dependency Inversion applied to the execution domain: `build-crate` does not know about `lint-crate`. It only knows it needs `linted-code`. Any node that produces `linted-code` satisfies the dependency. Nodes can be swapped, replaced, or parallelized without changing their consumers.
### 5. Three Independent Auth Planes
Authentication and authorization are split across three independent, non-substitutable planes:
| Plane | Technology | Scope | What it controls |
|-------|-----------|-------|-----------------|
| **Publisher auth** | NATS NKeys (ed25519) | Transport | Who can publish events to `dev.>` subjects |
| **Workflow authz** | Cedar policies | Orchestrator | Which pipelines/nodes a principal can trigger |
| **Execution credentials** | Vault (SecretumVault) | Per-node, per-step | Scoped secrets with TTL = node timeout |
Credentials from Vault are injected as environment variables into the Nushell subprocess and revoked on node failure. They never appear in NATS messages, logs, or `PipelineContext` (redacted before storage).
### 6. Saga Atomicity — Compensation, Not Transactions
Pipelines execute forward through stages. If a stage fails, the orchestrator does not roll back a database transaction — it runs `compensate.nu` scripts in reverse order through all previously successful stages. This is the Saga pattern:
```
Stage 0: lint (ok) + fmt (ok) → executed
Stage 1: build (FAIL) → trigger compensation
Stage 0 compensation: → undo lint, undo fmt (in parallel, reverse)
```
Compensation is best-effort: compensation failures are logged but do not block the pipeline from reaching `Compensated` status. The DB record captures the full compensation trace.
### 7. Parallel Stages via JoinSet + CancellationToken
Within each stage, nodes with no capability dependencies on each other execute in parallel using `tokio::task::JoinSet`. Fail-fast is implemented via `CancellationToken`: the first node failure cancels the token, aborting all sibling tasks in the stage.
```
Stage 0: [lint-crate ‖ fmt-crate] — parallel (no inter-dependency)
Stage 1: [build-crate] — sequential (needs both capabilities)
Stage 2: [install-crate] — sequential (needs built-artifact)
```
### 8. OCI for Everything — Content-Addressed Artifacts
Both node definitions and the Nickel base library are published as OCI artifacts to a Zot registry. The publish pipeline for each is: `nickel typecheck``gitleaks detect``nickel export``sha256sum``oras push` with content-hash annotations.
The `ncl-import-resolver` binary bridges OCI → local filesystem: it pulls each referenced OCI layer at orchestrator startup, verifies the digest against the annotated hash, then exposes a local path for Nickel imports. This prevents loading unverified or tampered node definitions.
This follows the same model as container images: build → scan → publish → consume by digest.
### 9. Nushell as Execution Unit — Agnostic by Design
Each action node's `handler` is a Nushell script. The executor spawns `nu --no-config-file <script.nu>`, passes `PipelineContext` inputs as JSON on stdin, and reads the output JSON from stdout. This makes execution:
- **Domain-agnostic**: the orchestrator has no knowledge of what the script does
- **Hot-replaceable**: updating a workflow means replacing a `.nu` file, not recompiling the binary
- **Sandboxable**: each node runs in its own process with scoped Vault credentials
- **Testable independently**: scripts can be invoked directly with `echo '{}' | nu script.nu`
### 10. TypeDialog Scope — Startup Config Only
TypeDialog is used exclusively for orchestrator startup configuration (SurrealDB URL, NATS URL, Zot URL, Vault URL, log level, feature flags). It is **not** used for project declaratives, workflow definitions, or node configurations. Those live in Nickel files managed per project. This prevents TypeDialog from becoming a catch-all config tool and keeps its scope bounded.
## Decision
`stratum-orchestrator` is implemented as a new crate family in the StratumIOps monorepo:
| Crate | Domain | Responsibility |
|-------|--------|----------------|
| `stratum-graph` | Knowledge | `ActionNode`, `Capability`, `GraphRepository` trait |
| `stratum-state` | Operational | `PipelineRun`, `StepRecord`, `StateTracker` trait |
| `platform-nats` | Transport | JetStream consumer with NKey auth |
| `stratum-orchestrator` | Coordination | `ActionGraph`, `PipelineContext`, `StageRunner`, auth, executor |
Domain isolation is structural: `stratum-graph` and `stratum-state` are separate crates with separate SurrealDB table namespaces. `stratum-orchestrator` depends on their traits, not their implementations — compile-time enforcement.
The orchestrator binary startup sequence: load TypeDialog config → connect SurrealDB → connect NATS → resolve OCI Nickel imports → build ActionGraph → start notify watcher → initialize Cedar policies → start HTTP server (health + agent callback) → enter JetStream pull loop.
## Rationale
### Why Not a General-Purpose Workflow Engine (Temporal, Argo, etc.)?
| Concern | External engine | stratum-orchestrator |
|---------|-----------------|----------------------|
| Cross-project event model | Requires adapter per project | Native NATS subject matching |
| Nickel integration | Not possible | First-class: nodes are `.ncl` files |
| Nushell execution | Not supported | Native subprocess executor |
| Operational footprint | Heavy (Temporal cluster, Argo K8s) | Single binary + SurrealDB + NATS |
| Custom auth model | Difficult to extend | Three planes designed in |
### Why Saga over 2PC?
Two-phase commit across distributed Nushell scripts is not feasible — scripts are external processes with no transaction coordinator. Saga compensation scripts (`compensate.nu`) are the only realistic atomicity model for cross-process workflows. The trade-off is accepted: compensation is best-effort, not guaranteed-atomic, but the failure cases are logged and auditable.
### Why In-Memory ActionGraph vs DB-Persisted Nodes?
Storing node definitions in SurrealDB creates dual truth. The file on disk and the DB record can diverge. Hot-reload via `notify` on the filesystem is simpler, faster, and eliminates the sync problem. SurrealDB is used only for operational state (pipeline runs, capability stores) — knowledge (node definitions) stays in the filesystem.
## Consequences
**Accepted trade-offs**:
- `nickel export` is a subprocess call per file at startup — adds ~50ms per node file to startup time. Mitigated by parallel load with `JoinSet` during startup.
- Saga compensation is best-effort — a compensation script that itself fails is logged but does not block status progression. This is a known Saga trade-off.
- Nushell subprocess overhead per node — each node execution spawns a process. For sub-second scripts this is observable latency. Acceptable for CI/CD and infrastructure workflows.
- OCI layer pull at startup — cold starts require pulling Nickel lib layers. Mitigated by local digest cache in `~/.cache/stratum/ncl/`.
**Benefits gained**:
- New workflows require only new `.ncl` files — zero orchestrator binary changes
- Full pipeline audit trail in SurrealDB: every step, every capability deposit, every compensation
- Crash recovery is free: restart the orchestrator, pipeline resumes from last persisted state
- Auth is non-negotiable: publisher identity (NKeys), workflow authorization (Cedar), and execution credentials (Vault) are enforced at every pipeline invocation
- Horizontal scaling: stateless orchestrator + shared SurrealDB enables multiple instances on the same event stream
## References
- Implementation plan: `.coder/2026-02-20-stratum-orchestrator-plan.plan.md`
- Architecture diagram: `assets/diagrams/arch-stratum-orchestrator.svg`
- Build pipeline flow: `assets/diagrams/flow-stratum-build-pipeline.svg`
- Nickel base library: `nickel/stratum-base/stratum-base.ncl`
- Crates: `crates/stratum-graph/`, `crates/stratum-state/`, `crates/platform-nats/`, `crates/stratum-orchestrator/`
- Related: ADR-001 (stratum-embeddings), ADR-002 (stratum-llm)

View File

@ -4,10 +4,11 @@ Architecture decision records for the STRATUMIOPS ecosystem.
## Active ADRs
| ID | Title | Status |
| -------------------------------- | --------------------------------------------- | -------- |
| [001](001-stratum-embeddings.md) | Stratum-Embeddings: Unified Embedding Library | Proposed |
| [002](002-stratum-llm.md) | Stratum-LLM: Unified LLM Provider Library | Proposed |
| ID | Title | Status |
| ---------------------------------------- | --------------------------------------------- | -------- |
| [001](001-stratum-embeddings.md) | Stratum-Embeddings: Unified Embedding Library | Proposed |
| [002](002-stratum-llm.md) | Stratum-LLM: Unified LLM Provider Library | Proposed |
| [003](003-stratum-orchestrator.md) | Stratum-Orchestrator: Graph-Driven Workflow Orchestrator | Accepted |
## Statuses

View File

@ -10,6 +10,7 @@ Decisiones arquitecturales documentadas siguiendo el formato ADR:
- [**ADR-001: Stratum-Embeddings**](adrs/001-stratum-embeddings.md) - Biblioteca unificada de embeddings
- [**ADR-002: Stratum-LLM**](adrs/002-stratum-llm.md) - Biblioteca unificada de providers LLM
- [**ADR-003: Stratum-Orchestrator**](adrs/003-stratum-orchestrator.md) - Orquestador de flujos guiado por grafo
## Formato ADR

View File

@ -0,0 +1,179 @@
# ADR-003: Stratum-Orchestrator — Orquestador de Flujos Guiado por Grafo
**Estado**: Aceptado
**Fecha**: 2026-02-20
## Contexto
El ecosistema StratumIOps abarca múltiples proyectos activos (provisioning, kogral, syntaxis, typedialog) que evolucionan concurrentemente con dependencias cruzadas. Cada proyecto dispara flujos de trabajo de build, validación, publicación y notificación en respuesta a cambios en el código. Antes de esta decisión, dichos flujos eran:
- Scripts ad-hoc por proyecto sin modelo de ejecución compartido
- No auditables — ningún registro duradero de qué paso se ejecutó, en qué orden, con qué resultado
- No componibles — un flujo en `provisioning` no podía reaccionar a un evento de `kogral`
- No seguros — sin atomicidad, sin rollback en fallo parcial, sin alcance de credenciales
- No escalables — añadir un proyecto nuevo implicaba copiar y adaptar scripts, acumulando deriva
El requisito central es un **orquestador de flujos agnóstico, dirigido por eventos y multi-proyecto**, capaz de coordinar pipelines de build, tareas de agentes IA y operaciones de infraestructura sin modificarse para cada nuevo proyecto o proveedor.
Se evaluaron dos enfoques de diseño:
| Enfoque | Descripción | Problema |
|---------|-------------|---------|
| **Router de reglas estáticas** | Mapear patrones de sujeto NATS a scripts en un fichero de configuración | Las reglas se multiplican con los proyectos; cambiar un flujo exige editar el router |
| **Motor guiado por grafo** | Los eventos atraviesan un DAG de nodos de acción declarados en Nickel; el orquestador es agnóstico | El orquestador nunca cambia para nuevos flujos |
Se adoptó el enfoque basado en grafo. El orquestador carga definiciones de nodos desde ficheros Nickel, construye un `ActionGraph` en memoria y ejecuta pipelines atravesando el grafo — no sabe qué hace el pipeline, solo cómo coordinarlo.
## Características Fundamentales del Diseño
### 1. Ejecución Guiada por Grafo, no Enrutamiento Estático
El orquestador no contiene tablas de enrutamiento. Los patrones de sujeto NATS se confrontan contra un `ActionGraph` construido a partir de definiciones de nodos en Nickel. Cada `ActionNode` declara:
- `trigger`: patrones de sujeto NATS que pueden activar el nodo como punto de entrada
- `input_schemas`: capacidades requeridas antes de la ejecución
- `output_schemas`: capacidades producidas tras la ejecución
- `compensate`: script de rollback opcional para atomicidad Saga
El grafo se construye recorriendo índices de productor/consumidor de capacidades. El ordenamiento topológico genera un plan de ejecución en etapas. **Añadir un nuevo flujo de trabajo implica añadir ficheros `.ncl` — el binario del orquestador no cambia nunca.**
### 2. Orquestador Sin Estado — DB-First
El proceso del orquestador no mantiene estado duradero. Cada escritura en `PipelineContext` va primero a SurrealDB y luego actualiza una caché en memoria (`DashMap`). En caso de caída, una instancia reiniciada reconstruye la caché desde la DB. Esto permite:
- **Escala horizontal**: múltiples instancias del orquestador comparten una SurrealDB sin split-brain
- **Recuperación ante caídas**: los pipelines reanudan desde la última capacidad persistida, no desde el principio
- **Observabilidad**: el estado completo del pipeline es consultable en cualquier momento sin instrumentar el proceso
### 3. Nickel como Única Fuente de Verdad — Sin Verdad Dual
Los nodos de acción, los esquemas de capacidad y la configuración de arranque del orquestador se definen en Nickel. No existe ningún proceso de indexación separado ni copia de las definiciones de nodos en base de datos usada en tiempo de ejecución. El `ActionGraph` se construye en memoria desde ficheros `.ncl` al arrancar mediante `nickel export --format json`, y se mantiene vivo mediante un observador de ficheros (`notify`) para recarga en caliente.
Esto elimina el problema de verdad dual: el fichero Nickel ES la definición. No existe riesgo de que un registro de base de datos diverja del fichero en disco.
### 4. Modelo de Capacidades — Inversión de Dependencia en el Dominio de Ejecución
Los nodos no dependen unos de otros directamente. Declaran las capacidades que producen y consumen. El motor de grafo resuelve las dependencias:
```
lint-crate → produce: linted-code
fmt-crate → produce: formatted-code
build-crate → consume: linted-code, formatted-code
→ produce: built-artifact
install-crate → consume: built-artifact
```
Esto es Inversión de Dependencia aplicada al dominio de ejecución: `build-crate` no conoce `lint-crate`. Solo sabe que necesita `linted-code`. Cualquier nodo que produzca `linted-code` satisface la dependencia. Los nodos pueden intercambiarse, reemplazarse o paralelizarse sin modificar sus consumidores.
### 5. Tres Planos de Autenticación Independientes
Autenticación y autorización están separadas en tres planos independientes, no intercambiables:
| Plano | Tecnología | Alcance | Qué controla |
|-------|-----------|--------|--------------|
| **Auth de publicador** | NATS NKeys (ed25519) | Transporte | Quién puede publicar eventos en sujetos `dev.>` |
| **Authz de flujo** | Políticas Cedar | Orquestador | Qué pipelines/nodos puede disparar un principal |
| **Credenciales de ejecución** | Vault (SecretumVault) | Por nodo, por paso | Secretos de alcance limitado con TTL = timeout del nodo |
Las credenciales de Vault se inyectan como variables de entorno en el subproceso Nushell y se revocan si el nodo falla. Nunca aparecen en mensajes NATS, logs ni en `PipelineContext` (se redactan antes del almacenamiento).
### 6. Atomicidad Saga — Compensación, no Transacciones
Los pipelines se ejecutan hacia adelante a través de etapas. Si una etapa falla, el orquestador no deshace una transacción de base de datos — ejecuta scripts `compensate.nu` en orden inverso a través de todas las etapas previamente exitosas. Este es el patrón Saga:
```
Etapa 0: lint (ok) + fmt (ok) → ejecutada
Etapa 1: build (FALLO) → dispara compensación
Compensación etapa 0: → deshacer lint, deshacer fmt (en paralelo, inverso)
```
La compensación es de mejor esfuerzo: los fallos de compensación se registran pero no impiden que el pipeline alcance el estado `Compensated`. El registro en DB captura la traza completa de compensación.
### 7. Etapas Paralelas con JoinSet + CancellationToken
Dentro de cada etapa, los nodos sin dependencias de capacidad entre sí se ejecutan en paralelo usando `tokio::task::JoinSet`. El fail-fast se implementa mediante `CancellationToken`: el primer fallo de un nodo cancela el token, abortando todas las tareas hermanas de la etapa.
```
Etapa 0: [lint-crate ‖ fmt-crate] — paralelo (sin dependencia mutua)
Etapa 1: [build-crate] — secuencial (necesita ambas capacidades)
Etapa 2: [install-crate] — secuencial (necesita built-artifact)
```
### 8. OCI para Todo — Artefactos con Direccionamiento por Contenido
Tanto las definiciones de nodos como la biblioteca base de Nickel se publican como artefactos OCI en un registro Zot. El pipeline de publicación para cada uno es: `nickel typecheck``gitleaks detect``nickel export``sha256sum``oras push` con anotaciones de hash de contenido.
El binario `ncl-import-resolver` hace de puente entre OCI y el sistema de ficheros local: descarga cada capa OCI referenciada al arrancar el orquestador, verifica el digest contra el hash anotado y expone una ruta local para las importaciones de Nickel. Esto impide cargar definiciones de nodos no verificadas o manipuladas.
Sigue el mismo modelo que las imágenes de contenedor: construir → escanear → publicar → consumir por digest.
### 9. Nushell como Unidad de Ejecución — Agnóstico por Diseño
El campo `handler` de cada nodo de acción apunta a un script Nushell. El executor lanza `nu --no-config-file <script.nu>`, pasa los inputs del `PipelineContext` como JSON en stdin y lee el JSON de salida desde stdout. Esto hace la ejecución:
- **Agnóstica al dominio**: el orquestador no conoce qué hace el script
- **Reemplazable en caliente**: actualizar un flujo de trabajo implica reemplazar un fichero `.nu`, no recompilar el binario
- **Aislable**: cada nodo se ejecuta en su propio proceso con credenciales Vault de alcance limitado
- **Testeable de forma independiente**: los scripts pueden invocarse directamente con `echo '{}' | nu script.nu`
### 10. Alcance de TypeDialog — Solo Config de Arranque
TypeDialog se usa exclusivamente para la configuración de arranque del orquestador (URL de SurrealDB, NATS, Zot, Vault, nivel de log, flags de funcionalidad). **No** se usa para declarativas de proyectos, definiciones de flujos ni configuración de nodos. Estas viven en ficheros Nickel gestionados por cada proyecto. Esto evita que TypeDialog se convierta en una herramienta de configuración general y mantiene su alcance acotado.
## Decisión
`stratum-orchestrator` se implementa como una familia de crates nuevos en el monorepo StratumIOps:
| Crate | Dominio | Responsabilidad |
|-------|---------|----------------|
| `stratum-graph` | Conocimiento | `ActionNode`, `Capability`, trait `GraphRepository` |
| `stratum-state` | Operacional | `PipelineRun`, `StepRecord`, trait `StateTracker` |
| `platform-nats` | Transporte | Consumidor JetStream con auth NKey |
| `stratum-orchestrator` | Coordinación | `ActionGraph`, `PipelineContext`, `StageRunner`, auth, executor |
El aislamiento de dominios es estructural: `stratum-graph` y `stratum-state` son crates separados con namespaces de tabla SurrealDB separados. `stratum-orchestrator` depende de sus traits, no de sus implementaciones — cumplimiento en tiempo de compilación.
Secuencia de arranque del binario: cargar config TypeDialog → conectar SurrealDB → conectar NATS → resolver importaciones Nickel OCI → construir ActionGraph → iniciar observador notify → inicializar políticas Cedar → iniciar servidor HTTP (health + callback agente) → entrar en bucle pull JetStream.
## Justificación
### ¿Por Qué No un Motor de Flujos de Propósito General (Temporal, Argo, etc.)?
| Consideración | Motor externo | stratum-orchestrator |
|--------------|---------------|----------------------|
| Modelo de eventos multi-proyecto | Requiere adaptador por proyecto | Coincidencia nativa de sujetos NATS |
| Integración Nickel | No viable | Primera clase: los nodos son ficheros `.ncl` |
| Ejecución Nushell | No soportado | Executor nativo de subprocesos |
| Coste operacional | Pesado (cluster Temporal, Argo en K8s) | Binario único + SurrealDB + NATS |
| Modelo de auth personalizado | Difícil de extender | Tres planos diseñados desde el inicio |
### ¿Por Qué Saga en vez de 2PC?
El commit en dos fases entre scripts Nushell distribuidos no es viable: los scripts son procesos externos sin coordinador de transacciones. Los scripts de compensación Saga (`compensate.nu`) son el único modelo de atomicidad realista para flujos de trabajo multi-proceso. La concesión es asumida: la compensación es de mejor esfuerzo, no garantizadamente atómica, pero los casos de fallo son registrados y auditables.
### ¿Por Qué ActionGraph en Memoria vs Nodos Persistidos en DB?
Almacenar las definiciones de nodos en SurrealDB crea verdad dual. El fichero en disco y el registro en DB pueden divergir. La recarga en caliente mediante `notify` sobre el sistema de ficheros es más simple, más rápida y elimina el problema de sincronización. SurrealDB solo se usa para el estado operacional (ejecuciones de pipeline, almacenes de capacidades) — el conocimiento (definiciones de nodos) permanece en el sistema de ficheros.
## Consecuencias
**Concesiones asumidas**:
- `nickel export` es una llamada a subproceso por fichero al arrancar — añade ~50ms por fichero de nodo al tiempo de arranque. Mitigado con carga paralela mediante `JoinSet` durante el arranque.
- La compensación Saga es de mejor esfuerzo — un script de compensación que falla se registra pero no bloquea la progresión de estado. Es una concesión conocida del patrón Saga.
- Coste de subproceso Nushell por nodo — cada ejecución de nodo lanza un proceso. Para scripts sub-segundo esto es latencia observable. Aceptable para flujos CI/CD e infraestructura.
- Descarga de capa OCI al arrancar — los arranques en frío requieren descargar capas de la biblioteca Nickel. Mitigado con caché local de digest en `~/.cache/stratum/ncl/`.
**Beneficios obtenidos**:
- Nuevos flujos de trabajo solo requieren nuevos ficheros `.ncl` — cero cambios en el binario del orquestador
- Traza de auditoría completa del pipeline en SurrealDB: cada paso, cada depósito de capacidad, cada compensación
- La recuperación ante caídas es gratuita: reiniciar el orquestador, el pipeline reanuda desde el último estado persistido
- La autenticación no es negociable: identidad del publicador (NKeys), autorización del flujo (Cedar) y credenciales de ejecución (Vault) se aplican en cada invocación de pipeline
- Escalado horizontal: orquestador sin estado + SurrealDB compartida permite múltiples instancias en el mismo stream de eventos
## Referencias
- Plan de implementación: `.coder/2026-02-20-stratum-orchestrator-plan.plan.md`
- Diagrama de arquitectura: `assets/diagrams/arch-stratum-orchestrator.svg`
- Flujo de pipeline de build: `assets/diagrams/flow-stratum-build-pipeline.svg`
- Biblioteca base Nickel: `nickel/stratum-base/stratum-base.ncl`
- Crates: `crates/stratum-graph/`, `crates/stratum-state/`, `crates/platform-nats/`, `crates/stratum-orchestrator/`
- Relacionados: ADR-001 (stratum-embeddings), ADR-002 (stratum-llm)

View File

@ -4,10 +4,11 @@ Registro de decisiones arquitecturales del ecosistema STRATUMIOPS.
## ADRs Activos
| ID | Título | Estado |
| -------------------------------- | ------------------------------------------------------ | --------- |
| [001](001-stratum-embeddings.md) | Stratum-Embeddings: Biblioteca Unificada de Embeddings | Propuesto |
| [002](002-stratum-llm.md) | Stratum-LLM: Biblioteca Unificada de Providers LLM | Propuesto |
| ID | Título | Estado |
| ---------------------------------------- | --------------------------------------------------------------- | --------- |
| [001](001-stratum-embeddings.md) | Stratum-Embeddings: Biblioteca Unificada de Embeddings | Propuesto |
| [002](002-stratum-llm.md) | Stratum-LLM: Biblioteca Unificada de Providers LLM | Propuesto |
| [003](003-stratum-orchestrator.md) | Stratum-Orchestrator: Orquestador de Flujos Guiado por Grafo | Aceptado |
## Estados

View File

@ -0,0 +1,60 @@
# stratum-base.ncl — shared Nickel types for stratum action nodes.
# Published as OCI artifact to Zot registry.
# Import: let base = import "nickel/stratum-base/stratum-base.ncl" in
let RetryPolicy = {
max | Number | default = 3,
backoff_secs | Number | default = 10,
strategy | [| 'fixed, 'linear, 'exponential |] | default = 'exponential,
}
in
let default_retry = {
max = 3,
backoff_secs = 10,
strategy = 'exponential,
}
in
let NodeDefinition = {
id | String,
handler | String,
input_schemas | { _ : String } | default = {},
output_schemas | { _ : String } | default = {},
compensate | std.option.Option String | default = std.option.None,
retry | RetryPolicy | default = default_retry,
timeout_secs | Number | default = 300,
atomic | Bool | default = true,
triggers | Array String | default = [],
}
in
let CapabilitySchema = {
name | String,
description | String,
schema | { _ : Dyn },
}
in
let SagaConfig = {
compensation_order | [| 'reverse, 'parallel |] | default = 'reverse,
fail_fast | Bool | default = true,
}
in
let ActionGroup = {
id | String,
nodes | Array NodeDefinition,
trigger | Array String,
saga | SagaConfig | default = {},
}
in
{
NodeDefinition,
RetryPolicy,
CapabilitySchema,
SagaConfig,
ActionGroup,
default_retry,
}

View File

@ -0,0 +1,11 @@
{
"$schema" = "http://json-schema.org/draft-07/schema#",
title = "built-artifact",
type = "object",
required = ["path", "crate_name", "ok"],
properties = {
path = { type = "string" },
crate_name = { type = "string" },
ok = { type = "boolean" },
},
}

View File

@ -0,0 +1,10 @@
{
"$schema" = "http://json-schema.org/draft-07/schema#",
title = "formatted-code",
type = "object",
required = ["ok"],
properties = {
ok = { type = "boolean" },
changed = { type = "integer" },
},
}

View File

@ -0,0 +1,11 @@
{
"$schema" = "http://json-schema.org/draft-07/schema#",
title = "installed",
type = "object",
required = ["binary_path", "version", "ok"],
properties = {
binary_path = { type = "string" },
version = { type = "string" },
ok = { type = "boolean" },
},
}

View File

@ -0,0 +1,12 @@
{
"$schema" = "http://json-schema.org/draft-07/schema#",
title = "linted-code",
description = "Output of lint-crate node",
type = "object",
required = ["warnings", "errors", "ok"],
properties = {
warnings = { type = "integer" },
errors = { type = "integer" },
ok = { type = "boolean" },
},
}

View File

@ -0,0 +1,15 @@
#!/usr/bin/env nu
# build-rollback.nu — compensation for build-crate.
# Removes build artifacts for the failed pipeline run.
def main []: nothing -> nothing {
let run_id = ($env | get --ignore-errors PIPELINE_RUN_ID | default "unknown")
print $"Compensating build for pipeline run [$run_id]"
let clean = (do { ^cargo clean } | complete)
if ($clean.exit_code != 0) {
print $"WARNING: cargo clean failed: ($clean.stderr)"
} else {
print "Build artifacts cleaned"
}
}

34
scripts/nu/build.nu Normal file
View File

@ -0,0 +1,34 @@
#!/usr/bin/env nu
# build.nu — run cargo build --release.
# Reads JSON inputs from stdin (linted-code, formatted-code).
# Emits JSON result to stdout.
def main []: nothing -> nothing {
let inputs = ($in | from json)
# Verify upstream capabilities are present
let linted = ($inputs | get "linted-code")
let formatted = ($inputs | get "formatted-code")
if (not ($linted.ok)) {
error make { msg: "linted-code.ok is false — refusing to build" }
}
if (not ($formatted.ok)) {
error make { msg: "formatted-code.ok is false — refusing to build" }
}
let result = (do { ^cargo build --release } | complete)
if ($result.exit_code != 0) {
error make { msg: $"cargo build failed:\n($result.stderr)" }
}
# Locate the produced binary (assumes single binary workspace)
let binary = (
^find target/release -maxdepth 1 -type f -perm /111 -not -name "*.d" -not -name "*.rlib"
| lines
| first
)
let crate_name = ($binary | path basename)
{ "built-artifact": { ok: true, path: $binary, crate_name: $crate_name } } | to json | print
}

16
scripts/nu/fmt.nu Normal file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env nu
# fmt.nu — run cargo fmt --check on the crate.
# Reads JSON inputs from stdin. Emits JSON result to stdout.
def main []: nothing -> nothing {
let inputs = ($in | from json)
let _ = $inputs # inputs acknowledged but fmt needs none
let result = (do { ^cargo fmt -- --check } | complete)
let changed = if ($result.exit_code == 0) { 0 } else { 1 }
let fix = (do { ^cargo fmt } | complete)
let ok = ($fix.exit_code == 0)
{ "formatted-code": { ok: $ok, changed: $changed } } | to json | print
}

View File

@ -0,0 +1,27 @@
#!/usr/bin/env nu
# install-rollback.nu — compensation for install-crate.
# Removes the installed binary if it was placed during a now-failed pipeline.
def main []: nothing -> nothing {
let run_id = ($env | get --ignore-errors PIPELINE_RUN_ID | default "unknown")
print $"Compensating install for pipeline run [$run_id]"
# The binary name must be deterministic — read from env or derive from cargo metadata
let crate_name = ($env | get --ignore-errors STRATUM_CRATE_NAME | default "")
if ($crate_name | is-empty) {
print "STRATUM_CRATE_NAME not set — skipping install rollback"
return
}
let target = ($nu.home-dir | path join ".local" "bin" $crate_name)
if ($target | path exists) {
let rm_result = (do { ^rm $target } | complete)
if ($rm_result.exit_code != 0) {
print $"WARNING: failed to remove ($target): ($rm_result.stderr)"
} else {
print $"Removed ($target)"
}
} else {
print $"($target) not present — nothing to remove"
}
}

35
scripts/nu/install.nu Normal file
View File

@ -0,0 +1,35 @@
#!/usr/bin/env nu
# install.nu — install a built artifact to ~/.local/bin.
# Reads JSON inputs from stdin (built-artifact).
# Emits JSON result to stdout.
def main []: nothing -> nothing {
let inputs = ($in | from json)
let artifact = ($inputs | get "built-artifact")
if (not ($artifact.ok)) {
error make { msg: "built-artifact.ok is false — refusing to install" }
}
let src = $artifact.path
let target = ($nu.home-dir | path join ".local" "bin" $artifact.crate_name)
let cp_result = (do { ^cp $src $target } | complete)
if ($cp_result.exit_code != 0) {
error make { msg: $"cp failed: ($cp_result.stderr)" }
}
let chmod_result = (do { ^chmod +x $target } | complete)
if ($chmod_result.exit_code != 0) {
error make { msg: $"chmod failed: ($chmod_result.stderr)" }
}
let version_result = (do { nu -c $"($target) --version" } | complete)
let version = if ($version_result.exit_code == 0) {
$version_result.stdout | str trim
} else {
"unknown"
}
{ "installed": { ok: true, binary_path: $target, version: $version } } | to json | print
}

16
scripts/nu/lint.nu Normal file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env nu
# lint.nu — run cargo clippy on the crate in the current directory.
# Reads JSON inputs from stdin. Emits JSON result to stdout.
def main []: nothing -> nothing {
let inputs = ($in | from json)
let crate_path = ($inputs | get --ignore-errors crate_path | default ".")
let result = (do { ^cargo clippy --all-features -- -D warnings } | complete)
let ok = ($result.exit_code == 0)
let warnings = if $ok { 0 } else { 1 }
let errors = if $ok { 0 } else { 1 }
{ "linted-code": { ok: $ok, warnings: $warnings, errors: $errors } } | to json | print
}

26
scripts/nu/notify.nu Normal file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env nu
# notify.nu — emit a NATS result event after successful install.
# Reads JSON inputs from stdin (installed).
# Emits empty JSON object to stdout (no output capabilities).
def main []: nothing -> nothing {
let inputs = ($in | from json)
let installed = ($inputs | get "installed")
if (not ($installed.ok)) {
error make { msg: "installed.ok is false — not notifying" }
}
let run_id = ($env | get --ignore-errors PIPELINE_RUN_ID | default "unknown")
let payload = { run_id: $run_id, binary: $installed.binary_path, version: $installed.version }
let pub_result = (do {
^nats pub dev.crate.built ($payload | to json)
} | complete)
if ($pub_result.exit_code != 0) {
error make { msg: $"nats pub failed: ($pub_result.stderr)" }
}
{} | to json | print
}

View File

@ -0,0 +1,68 @@
#!/usr/bin/env nu
# publish-ncl-lib.nu — Publish a Nickel library as an OCI artifact to Zot registry.
# Usage: nu publish-ncl-lib.nu --lib-dir <path> --registry <url> --name <name> --tag <tag>
def main [
--lib-dir: string, # directory containing the .ncl file
--registry: string, # OCI registry URL e.g. localhost:5000
--name: string, # library name e.g. stratum-base
--tag: string, # version tag e.g. 0.1.0
]: nothing -> nothing {
print $"Publishing Nickel lib [$name]:[$tag] to [$registry]"
let ncl_file = ($lib_dir | path join $"($name).ncl")
# Step 1: typecheck
let check = (do { ^nickel typecheck $ncl_file } | complete)
if ($check.exit_code != 0) {
error make { msg: $"nickel typecheck failed:\n($check.stderr)" }
}
print "typecheck passed"
# Step 2: gitleaks secret scan
let leak = (do { ^gitleaks detect --source $lib_dir --no-git } | complete)
if ($leak.exit_code != 0) {
error make { msg: $"gitleaks detected secrets in [$lib_dir]:\n($leak.stdout)" }
}
print "gitleaks clean"
# Step 3: export JSON artifact
let export_path = ($nu.temp-dir | path join $"($name)-export.json")
let export = (do { ^nickel export --format json $ncl_file } | complete)
if ($export.exit_code != 0) {
error make { msg: $"nickel export failed:\n($export.stderr)" }
}
$export.stdout | save --force $export_path
print $"nickel export → [$export_path]"
# Step 4: sha256
let sha_result = (do { ^sha256sum $export_path } | complete)
if ($sha_result.exit_code != 0) {
error make { msg: $"sha256sum failed: ($sha_result.stderr)" }
}
let sha = ($sha_result.stdout | split row " " | first)
print $"sha256: [$sha]"
# Step 5: oras push
let image_ref = $"($registry)/($name):($tag)"
let nickel_ver_result = (do { ^nickel --version } | complete)
let nickel_ver = if ($nickel_ver_result.exit_code == 0) {
$nickel_ver_result.stdout | str trim
} else {
"unknown"
}
let push = (do {
^oras push $image_ref
$"($export_path):application/vnd.stratumiops.ncl.export.v1+json"
--annotation $"org.stratumiops.lib.name=($name)"
--annotation $"org.stratumiops.lib.sha256=($sha)"
--annotation $"org.stratumiops.lib.tag=($tag)"
--annotation $"org.stratumiops.lib.nickel-version=($nickel_ver)"
} | complete)
if ($push.exit_code != 0) {
error make { msg: $"oras push failed:\n($push.stderr)" }
}
print $"pushed [$image_ref]"
}

View File

@ -0,0 +1,39 @@
#!/usr/bin/env nu
# start-dev-stack.nu — Bring up the stratum docker-compose dev stack and wait for health.
def wait-for-http [url: string, label: string, max_secs: int]: nothing -> nothing {
let start = (date now)
loop {
let elapsed = ((date now) - $start | into int) / 1_000_000_000
if ($elapsed > $max_secs) {
error make { msg: $"[$label] not ready after [$max_secs]s" }
}
let r = (do { ^curl -sf $url } | complete)
if ($r.exit_code == 0) {
print $"[$label] ready"
break
}
sleep 2sec
}
}
def main []: nothing -> nothing {
print "Starting stratum dev stack..."
let up = (do {
^docker compose -f docker-compose.dev.yml up -d
} | complete)
if ($up.exit_code != 0) {
error make { msg: $"docker compose up failed:\n($up.stderr)" }
}
print "Containers started, waiting for services..."
wait-for-http "http://localhost:8100/health" "SurrealDB" 30
wait-for-http "http://localhost:5000/v2/" "Zot OCI" 30
print "Dev stack ready"
print " SurrealDB: ws://localhost:8100"
print " Zot OCI: http://localhost:5000"
}

View File

@ -0,0 +1,34 @@
#!/usr/bin/env nu
# start-orchestrator.nu — Start the stratum orchestrator with full pre-flight checks.
def main [
--config: string = "config/orchestrator-config.ncl",
--skip-typecheck = false,
]: nothing -> nothing {
print "Starting orchestrator..."
# 1. Typecheck all .ncl files
if (not $skip_typecheck) {
print "Running Nickel typecheck..."
let tc = (do { ^nu scripts/nu/typecheck-all-ncl.nu } | complete)
if ($tc.exit_code != 0) {
error make { msg: $"Nickel typecheck failed — fix errors before starting orchestrator:\n($tc.stderr)" }
}
print "Nickel typecheck passed"
}
# 2. Verify config file exists and typechecks
if (not ($config | path exists)) {
error make { msg: $"Config file not found: [$config]" }
}
let cfg_check = (do { ^nickel typecheck $config } | complete)
if ($cfg_check.exit_code != 0) {
error make { msg: $"Orchestrator config failed typecheck:\n($cfg_check.stderr)" }
}
print $"Config typechecked: [$config]"
# 3. Exec orchestrator binary
print "Launching orchestrator..."
^./target/debug/orchestrator --config $config
}

View File

@ -0,0 +1,24 @@
#!/usr/bin/env nu
# typecheck-all-ncl.nu — Run nickel typecheck on all .ncl files. Exit 1 on any failure.
def main []: nothing -> nothing {
let all_files = (glob "**/*.ncl" | where { |f|
not ($f | str contains "/.git/")
})
let results = ($all_files | each { |f|
let r = (do { ^nickel typecheck $f } | complete)
{ file: $f, ok: ($r.exit_code == 0), err: $r.stderr }
})
let failures = ($results | where { |r| not $r.ok })
if (($failures | length) > 0) {
$failures | each { |f|
print $"FAIL ($f.file):\n($f.err)"
}
error make { msg: $"($failures | length) Nickel typecheck failures" }
}
print $"($results | length) .ncl files typechecked"
}

View File

@ -0,0 +1,12 @@
{
name = "orchestrator-dev",
services = [
{
name = "stratum-orchestrator",
binary = "target/debug/orchestrator",
config = "config/orchestrator-config.ncl",
depends_on = ["surrealdb-stratum", "nats"],
}
],
external_services = ["surrealdb-stratum", "zot-stratum", "nats"],
}