///|
/// Capability validation scope and timing. Existing compiled plans always remain strict.
/// AdmissionPolicy remains a source-compatible alias for existing consumers.
#derive(Eq, Debug)
#alias(AdmissionPolicy)
pub(all) enum CapabilityPolicy {
Strict
Permissive
}
///|
pub extend CapabilityPolicy with Eq::{equal, not_equal}
///|
pub extend CapabilityPolicy with @debug.Debug::{to_repr}
///|
pub(all) suberror SchemePlanError {
// Historical public error constructor, retained for source compatibility.
AdmissionInvalid(String)
}
///|
/// A validated Scheme call snapshot. No caller-owned arrays or maps are retained.
pub struct SchemePlan {
priv source : Bytes
priv unit_id : String
priv suite : Bytes
priv check_policy : CapabilityPolicy
priv entries : Array[@protocol.PublicEntry]
priv definitions : Array[@protocol.CustomType]
priv limits : @data.Limits
}
///|
pub fn SchemePlan::policy(self : SchemePlan) -> CapabilityPolicy {
self.check_policy
}
///|
/// Fresh observation only; editing it cannot change the validated declarations.
pub fn SchemePlan::boundary_json(self : SchemePlan) -> Json {
scheme_boundary_json(self.entries, self.definitions)
}
///|
fn scheme_boundary_json(
entries : Array[@protocol.PublicEntry],
definitions : Array[@protocol.CustomType],
) -> Json {
{
"entries": entries.map(entry => {
(
{
"name": entry.name,
"inputs": entry.inputs.map(t => t.to_json()),
"output": entry.output.to_json(),
} : Json)
}),
"definitions": definitions.map(definition => {
(
{
"name": definition.name,
"constructors": definition.constructors.map(variant => {
(
{
"name": variant.name,
"inputs": variant.inputs.map(t => t.to_json()),
} : Json)
}),
} : Json)
}),
}
}
///|
/// A separate interpreter plan. Strict selection retains the existing generator
/// gate; permissive selection never constructs a generated library or driver.
pub fn plan_scheme(
unit : @data.IRUnit,
suite : @protocol.Suite,
policy~ : CapabilityPolicy = Strict,
limits~ : @data.Limits = @data.Limits::default(),
) -> SchemePlan raise {
if policy is Strict {
ignore(plan(unit, suite))
}
suite.validate()
unit.validate(limits~)
let registry = @data.Registry::new(limits~)
let source = match registry.encode("ion-binary", unit) {
Binary(bytes) => bytes
Text(_) => raise AdmissionInvalid("execution.invalid_source")
}
let captured = registry.decode("ion-binary", {
unit_id: unit.id,
content: Binary(source),
})
let encoded_suite = suite.encode("json")
let suite = @protocol.Suite::decode(encoded_suite, "json")
let (entries, definitions) = selected_boundaries(captured.distribution, suite)
@protocol.validate_invocations(suite, entries, definitions)
if policy is Permissive {
for entry in entries {
ignore(
@backend.selected_file(
captured.distribution,
@ir.FQName::parse(entry.name),
),
) catch {
@backend.SelectedReferenceInvalid(code) => raise AdmissionInvalid(code)
error => raise error
}
}
}
let selected = SchemePlan::{
source,
unit_id: captured.id,
suite: encoded_suite,
check_policy: policy,
entries,
definitions,
limits,
}
// Descriptor count and name/dimension bounds constrain expansion before this
// byte check, including repeated aliases. Callers only receive validated plans.
if @utf8.encode(selected.boundary_json().stringify()).length() > 1048576 {
raise AdmissionInvalid("execution.type_limit")
}
selected
}
///|
pub fn SchemePlan::validate_outputs(
self : SchemePlan,
values : Array[@protocol.Value],
) -> Unit raise {
let suite = @protocol.Suite::decode(self.suite, "json")
if values.length() != suite.calls.length() {
raise AdmissionInvalid("execution.output_inventory")
}
for i, call in suite.calls {
let entry = self.entries.filter(e => e.name == call.entry)[0]
@protocol.validate_as(
values[i],
entry.output,
self.definitions,
output=true,
)
validate_sdk_boundary(values[i])
}
}
///|
pub fn SchemePlan::evaluate(self : SchemePlan) -> Array[@protocol.Value] raise {
let unit = @data.Registry::new(limits=self.limits).decode("ion-binary", {
unit_id: self.unit_id,
content: Binary(self.source),
})
let suite = @protocol.Suite::decode(self.suite, "json")
let results = []
for call in suite.calls {
let entry = self.entries.filter(e => e.name == call.entry)[0]
let runtime = @scheme.Runtime::new(limits={
steps: 1000000,
depth: 128,
frames: 100000,
})
@backend.install(runtime, accelerators=false, model_errors=true)
let result = try {
let selected = if self.check_policy is Permissive {
@backend.selected_file(unit.distribution, @ir.FQName::parse(call.entry))
} else {
unit.distribution
}
let value = @backend.run_file_in(
runtime,
selected,
@ir.FQName::parse(call.entry),
call.arguments.map(scheme_input),
)
scheme_output(value, entry.output, self.definitions, 0)
} catch {
@scheme.Domain(code, _) => @protocol.ModelError(code)
@backend.SelectedReferenceInvalid(code) => raise AdmissionInvalid(code)
error => raise error
}
@protocol.validate_as(result, entry.output, self.definitions, output=true)
results.push(result)
}
self.validate_outputs(results)
results
}
///|
fn selected_boundaries(
file : @ir.IRFile,
suite : @protocol.Suite,
) -> (Array[@protocol.PublicEntry], Array[@protocol.CustomType]) raise {
guard file.distribution is Library(package_name, _, package_def) else {
raise AdmissionInvalid("execution.unsupported_distribution")
}
let types : Map[String, SelectedType] = Map([])
let constructor_names : Map[String, Bool] = Map([])
for module_name, module_def in package_def.modules {
for local_name, definition in module_def.value.types {
let name : @ir.FQName = { package_name, module_name, local_name, }
types[name.to_canonical()] = {
definition: definition.value.value,
public: module_def.access is Public && definition.access is Public,
exposed: definition.access is Public,
module_name,
}
if definition.value.value is CustomTypeDefinition(_, variants) {
for local_name, _ in variants.value {
let name : @ir.FQName = { package_name, module_name, local_name, }
let key = name.to_canonical()
if constructor_names.contains(key) {
raise AdmissionInvalid("execution.ambiguous_identity")
}
constructor_names[key] = true
}
}
}
}
let context = SelectedBoundary::{
types,
definitions: Map([]),
nodes: Ref(0),
}
let entries : Array[@protocol.PublicEntry] = []
for call in suite.calls {
let name = @ir.FQName::parse(call.entry)
guard name.package_name == package_name &&
package_def.modules.get(name.module_name) is Some(module_def) &&
module_def.access is Public &&
module_def.value.values.get(name.local_name) is Some(definition) &&
definition.access is Public &&
definition.value.value.to_specification() is Some(specification) else {
raise @protocol.Invalid("execution.entry_not_public_or_supported")
}
if !entries.any(entry => entry.name == call.entry) {
if specification.inputs.length() > 64 {
raise AdmissionInvalid("execution.type_limit")
}
entries.push(@protocol.PublicEntry::{
name: call.entry,
inputs: specification.inputs.map(p => {
context.read(p.1, 0, [], name.module_name)
}),
output: context.read(specification.output, 0, [], name.module_name),
})
}
}
entries.sort_by(fn(a, b) { a.name.lexical_compare(b.name) })
let names = context.definitions.keys().collect()
names.sort_by(fn(a, b) { a.lexical_compare(b) })
(entries, names.map(name => context.definitions[name]))
}
///|
/// Discover one complete public type boundary without compiling executable code.
/// This is an input-editor description, not permission to execute. A run must
/// still use plan_scheme_capabilities to validate its source, inputs and policy.
pub fn scheme_entry_boundary(
file : @ir.IRFile,
entry : String,
) -> (@protocol.PublicEntry, Array[@protocol.CustomType]) raise {
let suite = @protocol.Suite::from_json({
"profile": "morphir-invocations-v1",
"calls": [{ "id": "boundary", "entry": entry, "arguments": [] }],
})
let (entries, definitions) = selected_boundaries(file, suite)
if @utf8.encode(scheme_boundary_json(entries, definitions).stringify()).length() >
1048576 {
raise AdmissionInvalid("execution.type_limit")
}
(entries[0], definitions)
}
///|
priv struct SelectedType {
definition : @ir.TypeDefinition
public : Bool
exposed : Bool
module_name : @ir.ModuleName
}
///|
priv struct SelectedBoundary {
types : Map[String, SelectedType]
definitions : Map[String, @protocol.CustomType]
nodes : Ref[Int32]
}
///|
fn SelectedBoundary::count(self : SelectedBoundary) -> Unit raise {
self.nodes.val += 1
if self.nodes.val > 10000 {
raise AdmissionInvalid("execution.type_limit")
}
}
///|
fn selected_type_name(name : String) -> Unit raise {
if name.length() == 0 || name.length() > 1024 {
raise AdmissionInvalid("execution.type_limit")
}
}
///|
fn SelectedBoundary::read(
self : SelectedBoundary,
t : @ir.Type,
depth : Int32,
aliases : Array[String],
requester : @ir.ModuleName,
) -> @protocol.ValueType raise {
self.count()
if depth > 64 {
raise AdmissionInvalid("execution.type_limit")
}
let attributes = match t {
Variable(a, _)
| Reference(a, _, _)
| Tuple(a, _)
| Union(a, _)
| Record(a, _)
| ExtensibleRecord(a, _, _)
| Function(a, _, _)
| Unit(a) => a
}
if !attributes.constraints.is_empty() {
raise AdmissionInvalid("execution.unsupported_type")
}
fn child(t : @ir.Type) -> @protocol.ValueType raise {
self.read(t, depth + 1, aliases, requester)
}
match t {
Unit(_) => @protocol.Unit
Function(_, _, _) => raise AdmissionInvalid("execution.boundary_function")
Variable(_, _) | ExtensibleRecord(_, _, _) =>
raise AdmissionInvalid("execution.unsupported_type")
Union(_, _) => raise AdmissionInvalid("execution.unsupported_type")
Tuple(_, items) => {
if items.length() > 1024 {
raise AdmissionInvalid("execution.type_limit")
}
if items.length() < 2 {
raise AdmissionInvalid("execution.unsupported_type")
}
@protocol.Product(items.map(child))
}
Record(_, fields) => {
if fields.length() > 1024 {
raise AdmissionInvalid("execution.type_limit")
}
let names = fields.keys().collect()
names.sort_by(fn(a, b) {
a.to_canonical().lexical_compare(b.to_canonical())
})
@protocol.Record(
names.map(name => {
self.count()
selected_type_name(name.to_canonical())
(name.to_canonical(), child(fields[name]))
}),
)
}
Reference(_, name, arguments) => {
let key = name.to_canonical()
selected_type_name(key)
match key {
"morphir/SDK:list#list" if arguments.length() == 1 =>
return @protocol.Sequence(child(arguments[0]))
"morphir/SDK:maybe#maybe" if arguments.length() == 1 =>
return @protocol.Optional(child(arguments[0]))
"morphir/SDK:result#result" if arguments.length() == 2 =>
return @protocol.Outcome(child(arguments[1]), child(arguments[0]))
_ => ()
}
if !arguments.is_empty() {
raise AdmissionInvalid("execution.unsupported_type")
}
match key {
"morphir/SDK:int#int-32" => {
self.definitions[key] = {
name: key,
constructors: [
{ name: "morphir/SDK:int#int32", inputs: [@protocol.Int], },
],
}
@protocol.Custom(key)
}
"morphir/SDK:int#int-16"
| "morphir/SDK:int#int-64"
| "morphir/SDK:int#byte"
| "morphir/SDK:int#uint-16"
| "morphir/SDK:int#uint-32"
| "morphir/SDK:int#uint-64" => {
let suffix = key
.clamped_view(start="morphir/SDK:int#".length())
.to_owned()
let tag = "morphir/SDK:int#" +
(if suffix.has_prefix("int-") {
suffix.replace(old="-", new="")
} else {
suffix
})
self.definitions[key] = {
name: key,
constructors: [{ name: tag, inputs: [@protocol.Int], }],
}
@protocol.Custom(key)
}
"morphir/SDK:basics#int" => @protocol.Int
"morphir/SDK:basics#float" => @protocol.Float64
"morphir/moonbit-source:float32#float32" => @protocol.Float64
"morphir/SDK:basics#bool" => @protocol.Bool
"morphir/SDK:string#string" => @protocol.Text
"morphir/SDK:char#char" => @protocol.Character
"morphir/SDK:decimal#decimal" => @protocol.Decimal
_ => {
guard self.types.get(key) is Some(entry) else {
raise AdmissionInvalid("execution.unresolved_custom_type")
}
if !entry.exposed && entry.module_name != requester {
raise AdmissionInvalid("execution.constructor_visibility")
}
match entry.definition {
TypeAliasDefinition(parameters, target) => {
if !parameters.is_empty() || aliases.contains(key) {
raise AdmissionInvalid("execution.unsupported_type")
}
self.read(target, depth + 1, aliases + [key], entry.module_name)
}
CustomTypeDefinition(parameters, variants) => {
if !parameters.is_empty() {
raise AdmissionInvalid("execution.unsupported_type")
}
if !entry.public || variants.access is Private {
raise AdmissionInvalid("execution.constructor_visibility")
}
if !self.definitions.contains(key) {
if self.definitions.length() >= 256 ||
variants.value.length() > 256 {
raise AdmissionInvalid("execution.type_limit")
}
self.count()
self.definitions[key] = { name: key, constructors: [], }
let names = variants.value.keys().collect()
names.sort_by(fn(a, b) {
a.to_canonical().lexical_compare(b.to_canonical())
})
let constructors = names.map(local_name => {
self.count()
let fields = variants.value[local_name]
if fields.length() > 64 {
raise AdmissionInvalid("execution.type_limit")
}
let fq : @ir.FQName = { ..name, local_name, }
selected_type_name(fq.to_canonical())
@protocol.ConstructorType::{
name: fq.to_canonical(),
inputs: fields.map(p => {
self.read(p.1, 0, [], name.module_name)
}),
}
})
self.definitions[key] = { name: key, constructors, }
}
@protocol.Custom(key)
}
IncompleteTypeDefinition(_, _, _) =>
raise AdmissionInvalid("execution.unsupported_type")
}
}
}
}
}
}
///|
/// Return a newly decoded source snapshot. Editing it cannot change this plan.
pub fn SchemePlan::unit(self : SchemePlan) -> @data.IRUnit raise {
@data.Registry::new(limits=self.limits).decode("ion-binary", {
unit_id: self.unit_id,
content: Binary(self.source),
})
}
///|
/// Return a newly decoded invocation suite, independent of retained arguments.
pub fn SchemePlan::suite(self : SchemePlan) -> @protocol.Suite raise {
@protocol.Suite::decode(self.suite, "json")
}