From 07043df8327ffddcb68114328bdd5339414677ba Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 12:16:32 +0330 Subject: [PATCH 01/10] fix(runtime): preserve structured errors during extension load --- lua/cdin/runtime/extension/sandbox.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lua/cdin/runtime/extension/sandbox.lua b/lua/cdin/runtime/extension/sandbox.lua index da04b9a..de02501 100644 --- a/lua/cdin/runtime/extension/sandbox.lua +++ b/lua/cdin/runtime/extension/sandbox.lua @@ -117,6 +117,7 @@ function Sandbox:_environment(plugin_name) local ok, result = pcall(chunk) self.active[module_name] = nil if not ok then + if errors.is(result) then error(result, 0) end errors.raise("BAD_EXTENSION", "extension module failed", { module = module_name, cause = tostring(result), @@ -177,6 +178,7 @@ function Sandbox:load(plugin_name, source, core_facade, config, chunk_name) local ok, module = pcall(chunk) if not ok then + if errors.is(module) then error(module, 0) end errors.raise("BAD_EXTENSION", "extension entry failed", { plugin = plugin_name, cause = tostring(module), @@ -201,6 +203,7 @@ function Sandbox:load(plugin_name, source, core_facade, config, chunk_name) if module.init then local init_ok, init_error = pcall(module.init, core_facade, config) if not init_ok then + if errors.is(init_error) then error(init_error, 0) end errors.raise("BAD_EXTENSION", "extension init failed", { plugin = plugin_name, cause = tostring(init_error), From e4c53299d5fbd17e2c9b4a832e7a24ae70e26983 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 12:16:36 +0330 Subject: [PATCH 02/10] test(runtime): preserve structured Backend resolver errors --- tests/integration/run.lua | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/integration/run.lua b/tests/integration/run.lua index 46ce718..97e7f12 100644 --- a/tests/integration/run.lua +++ b/tests/integration/run.lua @@ -287,6 +287,29 @@ end A.truthy(resolver_runtime:shutdown()) +-- Structured Backend resolver failures must survive nested require and +-- extension initialization instead of being flattened to BAD_EXTENSION. +local structured_error_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function(_, module_name) + if module_name == "init" then + return [[return { init = function() require("helpers.too_large") end }]] + end + error({ + domain = "cdin.runtime", + code = "LIMIT_EXCEEDED", + message = "extension source exceeds configured size", + details = { module = module_name }, + }) + end, +}) +structured_error_runtime:start() +A.error_code(function() + structured_error_runtime:load_extension("oversized-extension", {}) +end, "LIMIT_EXCEEDED") +A.equal(#structured_error_runtime:list_extensions(), 0) +A.truthy(structured_error_runtime:shutdown()) + local missing_resolver_runtime = Runtime.new({ host = FakeHost.new(), module_resolver = function() return nil end, From 6451f7db1b6a7984dff9a66a1f856e4f2a48c410 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 12:16:39 +0330 Subject: [PATCH 03/10] docs(runtime): define structured resolver error propagation --- docs/contracts/extension-api.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/contracts/extension-api.md b/docs/contracts/extension-api.md index e1047dd..80a2aae 100644 --- a/docs/contracts/extension-api.md +++ b/docs/contracts/extension-api.md @@ -70,8 +70,11 @@ For each extension, Backend resolves the entry module name `init` inside that extension's namespace. Nested Lua `require("sub.module")` calls use the same resolver and the same owning extension name. The resolver returns the exact Lua source string and may return a virtual chunk name for diagnostics. It must -return `nil` for a module that does not exist and must not expose absolute host -paths in chunk names or error messages. +return `nil` for a module that does not exist. For access, I/O or limit +failures, it may raise a structured Runtime error in the `cdin.runtime` domain; +Runtime preserves that code through nested module loading and extension +initialization. Resolvers must not expose absolute host paths in chunk names or +error messages. Trusted embedders may use the following Runtime methods: From f1073de3d18d191abf90358df682042c3cf338c3 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 12:22:53 +0330 Subject: [PATCH 04/10] test(runtime): expect permission denial for forbidden globals --- tests/unit/run.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/run.lua b/tests/unit/run.lua index 9c84d0b..6cf39ba 100644 --- a/tests/unit/run.lua +++ b/tests/unit/run.lua @@ -167,7 +167,9 @@ local module = sandbox:load("safe", "return { init = function(core, config) core commands = { register = function(_, name) A.equal(name, "safe.ok") end }, keymap = {}, events = {}, log = function() end, ui = {}, }, {}) A.equal(type(module.init), "function") -A.error_code(function() sandbox:load("unsafe", "return { init = function() return os.execute('x') end }", {}, {}) end, "BAD_EXTENSION") +-- Accessing a forbidden global is a capability denial, not a generic malformed +-- extension. Preserve the sandbox's stable authorization error code. +A.error_code(function() sandbox:load("unsafe", "return { init = function() return os.execute('x') end }", {}, {}) end, "PERMISSION_DENIED") A.error_code(function() sandbox:load("global", "leak = true; return {}", {}, {}) end, "BAD_EXTENSION") require("unit.log_tests") From 0f3b39820b735eaeadc46c4b9349b063a1104078 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 12:24:04 +0330 Subject: [PATCH 05/10] test(runtime): expect permission denial for sandbox global writes --- tests/unit/run.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/run.lua b/tests/unit/run.lua index 6cf39ba..0a1c086 100644 --- a/tests/unit/run.lua +++ b/tests/unit/run.lua @@ -170,7 +170,7 @@ A.equal(type(module.init), "function") -- Accessing a forbidden global is a capability denial, not a generic malformed -- extension. Preserve the sandbox's stable authorization error code. A.error_code(function() sandbox:load("unsafe", "return { init = function() return os.execute('x') end }", {}, {}) end, "PERMISSION_DENIED") -A.error_code(function() sandbox:load("global", "leak = true; return {}", {}, {}) end, "BAD_EXTENSION") +A.error_code(function() sandbox:load("global", "leak = true; return {}", {}, {}) end, "PERMISSION_DENIED") require("unit.log_tests") From 856690139fc40971e496cf03d556b87b18556cdc Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 13:16:36 +0330 Subject: [PATCH 06/10] test: cover structured resolver error boundaries --- tests/integration/run.lua | 82 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/tests/integration/run.lua b/tests/integration/run.lua index 97e7f12..f9dafbb 100644 --- a/tests/integration/run.lua +++ b/tests/integration/run.lua @@ -199,7 +199,10 @@ A.falsy(runtime.syntax:get("plugin-sample") ~= nil) local command_takeover = [[return { init = function(core) core.commands:register("document.undo", function() return "hijacked" end, { overwrite = true }) end }]] -A.error_code(function() runtime.plugins:load("takeover", command_takeover, {}) end, "BAD_EXTENSION") +local takeover_error = A.error_code(function() runtime.plugins:load("takeover", command_takeover, {}) end, "PERMISSION_DENIED") +A.equal(takeover_error.domain, "cdin.runtime") +A.equal(takeover_error.details.command, "document.undo") +A.equal(takeover_error.details.owner, "core") A.truthy(runtime.commands:get("document.undo")) A.equal(runtime.commands:get("document.undo").owner, "core") local failed_plugin = [[return { init = function(core) @@ -261,7 +264,7 @@ A.equal(#resolver_runtime:list_extensions(), 0) local module_path_escape = [[return { init = function() require("../outside") end }]] A.error_code(function() resolver_runtime.plugins:load("module-escape", module_path_escape, {}) -end, "BAD_EXTENSION") +end, "PERMISSION_DENIED") A.falsy(resolver_runtime.commands:get("module-escape.command")) -- Dotted module identifiers must accept ordinary segments and reject empty @@ -280,7 +283,7 @@ for index, module_name in ipairs(invalid_module_names) do local resolver_calls_before = #resolver_calls A.error_code(function() resolver_runtime.plugins:load(plugin_name, source, {}) - end, "BAD_EXTENSION") + end, "PERMISSION_DENIED") A.equal(#resolver_calls, resolver_calls_before) A.falsy(resolver_runtime.commands:get(plugin_name .. ".command")) end @@ -304,12 +307,83 @@ local structured_error_runtime = Runtime.new({ end, }) structured_error_runtime:start() -A.error_code(function() +local nested_resolver_error = A.error_code(function() structured_error_runtime:load_extension("oversized-extension", {}) end, "LIMIT_EXCEEDED") +A.equal(nested_resolver_error.domain, "cdin.runtime") +A.equal(nested_resolver_error.details.module, "helpers.too_large") A.equal(#structured_error_runtime:list_extensions(), 0) A.truthy(structured_error_runtime:shutdown()) + +-- A structured resolver failure while resolving the entry source must retain +-- its Backend code and details before any extension is registered. +local structured_entry_resolver_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function() + error({ + domain = "cdin.runtime", + code = "IO_ERROR", + message = "extension entry could not be read", + details = { module = "init" }, + }) + end, +}) +structured_entry_resolver_runtime:start() +local entry_resolver_error = A.error_code(function() + structured_entry_resolver_runtime:load_extension("entry-read-failure", {}) +end, "IO_ERROR") +A.equal(entry_resolver_error.domain, "cdin.runtime") +A.equal(entry_resolver_error.details.module, "init") +A.equal(#structured_entry_resolver_runtime:list_extensions(), 0) +A.truthy(structured_entry_resolver_runtime:shutdown()) + +-- Structured errors raised by the entry chunk and init hook must cross the +-- sandbox boundary unchanged; failed init still rolls back owned contributions. +local structured_entry_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function() + return [[error({ + domain = "cdin.runtime", + code = "IO_ERROR", + message = "entry execution failed", + details = { stage = "entry" }, + })]] + end, +}) +structured_entry_runtime:start() +local entry_error = A.error_code(function() + structured_entry_runtime:load_extension("entry-execution-failure", {}) +end, "IO_ERROR") +A.equal(entry_error.details.stage, "entry") +A.equal(#structured_entry_runtime:list_extensions(), 0) +A.truthy(structured_entry_runtime:shutdown()) + +local structured_init_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function() + return [[return { + init = function(core) + core.commands:register("structured.init.partial", function() return true end) + error({ + domain = "cdin.runtime", + code = "PERMISSION_DENIED", + message = "extension initialization was denied", + details = { stage = "init" }, + }) + end, + }]] + end, +}) +structured_init_runtime:start() +local init_error = A.error_code(function() + structured_init_runtime:load_extension("init-permission-failure", {}) +end, "PERMISSION_DENIED") +A.equal(init_error.details.stage, "init") +A.falsy(structured_init_runtime.commands:get("structured.init.partial")) +A.equal(#structured_init_runtime:list_extensions(), 0) +A.truthy(structured_init_runtime:shutdown()) + local missing_resolver_runtime = Runtime.new({ host = FakeHost.new(), module_resolver = function() return nil end, From 02bc4c4c614213593685fb83d254b1dab00e42c5 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 13:16:39 +0330 Subject: [PATCH 07/10] docs: define structured resolver error propagation --- docs/contracts/extension-api.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/contracts/extension-api.md b/docs/contracts/extension-api.md index 80a2aae..035bb00 100644 --- a/docs/contracts/extension-api.md +++ b/docs/contracts/extension-api.md @@ -71,10 +71,11 @@ extension's namespace. Nested Lua `require("sub.module")` calls use the same resolver and the same owning extension name. The resolver returns the exact Lua source string and may return a virtual chunk name for diagnostics. It must return `nil` for a module that does not exist. For access, I/O or limit -failures, it may raise a structured Runtime error in the `cdin.runtime` domain; -Runtime preserves that code through nested module loading and extension -initialization. Resolvers must not expose absolute host paths in chunk names or -error messages. +failures, it may raise a structured Runtime error in the `cdin.runtime` domain. +Runtime preserves that code when the resolver fails and through entry-module +execution, nested module loading and the extension `init` hook. Ordinary Lua +errors that are not recognized Runtime records remain `BAD_EXTENSION`. +Resolvers must not expose absolute host paths in chunk names or error messages. Trusted embedders may use the following Runtime methods: From da1fa74f7668a242c38d753d100d55f72c07944a Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 13:17:29 +0330 Subject: [PATCH 08/10] test: assert resolved extension command result correctly --- tests/integration/run.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/run.lua b/tests/integration/run.lua index f9dafbb..09fdc69 100644 --- a/tests/integration/run.lua +++ b/tests/integration/run.lua @@ -250,8 +250,8 @@ local resolved = resolver_runtime:load_extension("sample-resolved", { suffix = " A.equal(resolved.name, "sample-resolved") A.equal(resolved.state, "active") A.equal(resolved.source, "resolver") -A.equal(resolver_runtime.commands:execute("resolved.greeting", {}, {}), true) -local _, resolved_value = resolver_runtime.commands:execute("resolved.greeting", {}, {}) +local resolved_ok, resolved_value = resolver_runtime.commands:execute("resolved.greeting", {}, {}) +A.truthy(resolved_ok) A.equal(resolved_value, "hello!") A.equal(resolver_calls[1], "sample-resolved:init") A.equal(resolver_calls[2], "sample-resolved:helpers.greeting") From 198c9f4a4d9f33612c758c5f61e10ca2ecb02545 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 13:22:29 +0330 Subject: [PATCH 09/10] test: define extension duplicate unload and shutdown semantics --- tests/integration/run.lua | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/integration/run.lua b/tests/integration/run.lua index 09fdc69..0095253 100644 --- a/tests/integration/run.lua +++ b/tests/integration/run.lua @@ -248,6 +248,13 @@ A.error_code(function() end, "INVALID_ARGUMENT") local resolved = resolver_runtime:load_extension("sample-resolved", { suffix = "!" }) A.equal(resolved.name, "sample-resolved") +-- A duplicate load is idempotent: it returns the active record without +-- resolving modules again or re-running init with new configuration. +local resolver_calls_before_duplicate = #resolver_calls +local duplicate_resolved = resolver_runtime:load_extension("sample-resolved", { suffix = "ignored" }) +A.equal(duplicate_resolved.name, resolved.name) +A.equal(duplicate_resolved.state, "active") +A.equal(#resolver_calls, resolver_calls_before_duplicate) A.equal(resolved.state, "active") A.equal(resolved.source, "resolver") local resolved_ok, resolved_value = resolver_runtime.commands:execute("resolved.greeting", {}, {}) @@ -288,6 +295,59 @@ for index, module_name in ipairs(invalid_module_names) do A.falsy(resolver_runtime.commands:get(plugin_name .. ".command")) end +-- An unload-hook failure still releases every owner-scoped contribution and +-- removes the name from the active set. The structured failure is returned +-- only after cleanup has completed. +local unload_failure_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function() + return [[return { + init = function(core) + core.commands:register("unload.failure.command", function() return true end) + end, + unload = function() + error({ + domain = "cdin.runtime", + code = "PERMISSION_DENIED", + message = "extension cleanup was denied", + details = { stage = "unload" }, + }) + end, + }]] + end, +}) +unload_failure_runtime:start() +unload_failure_runtime:load_extension("unload-failure", {}) +A.truthy(unload_failure_runtime.commands:get("unload.failure.command")) +local unload_error = A.error_code(function() + unload_failure_runtime:unload_extension("unload-failure") +end, "PERMISSION_DENIED") +A.equal(unload_error.details.stage, "unload") +A.equal(#unload_failure_runtime:list_extensions(), 0) +A.equal(unload_failure_runtime.commands:get("unload.failure.command"), nil) +A.falsy(unload_failure_runtime:unload_extension("unload-failure")) +A.truthy(unload_failure_runtime:shutdown()) + +-- Manager shutdown drops owner registrations without invoking extension +-- unload hooks. A hook that would fail must not turn shutdown into a failure. +local shutdown_extension_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function() + return [[return { + init = function(core) + core.commands:register("shutdown.extension.command", function() return true end) + end, + unload = function() error("shutdown must not invoke extension unload") end, + }]] + end, +}) +shutdown_extension_runtime:start() +shutdown_extension_runtime:load_extension("shutdown-extension", {}) +A.truthy(shutdown_extension_runtime.commands:get("shutdown.extension.command")) +A.truthy(shutdown_extension_runtime:shutdown()) +A.equal(shutdown_extension_runtime.plugins.plugins["shutdown-extension"], nil) +A.equal(shutdown_extension_runtime.commands:get("shutdown.extension.command"), nil) + A.truthy(resolver_runtime:shutdown()) -- Structured Backend resolver failures must survive nested require and From 07d43f9b1aba96baf707635c5e821f6da217bb4f Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 6 Nov 2026 13:22:32 +0330 Subject: [PATCH 10/10] docs: specify deterministic extension lifecycle semantics --- docs/contracts/extension-api.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/contracts/extension-api.md b/docs/contracts/extension-api.md index 035bb00..6bb4919 100644 --- a/docs/contracts/extension-api.md +++ b/docs/contracts/extension-api.md @@ -29,9 +29,25 @@ return { return a table. Initialization failure rolls back registrations for that extension and does not call its unload hook. Explicit unload invokes `unload` once, then removes the extension's commands, keymap contributions and event -listeners. Runtime shutdown drops registrations without invoking extension -unload hooks; this preserves the stated current contract and avoids running -arbitrary extension code while the runtime is closing. +listeners, styles, themes and syntax contributions. Runtime shutdown drops +registrations without invoking extension unload hooks; this preserves the +stated current contract and avoids running arbitrary extension code while +the runtime is closing. + +### Duplicate, unload and shutdown semantics + +- Loading a name that is already active is idempotent: Runtime returns the + existing active record without resolving the entry again or re-running + `init`. There is no in-place hot reload; explicitly unload the extension + before loading that name again. +- If `unload()` raises, Runtime still removes the extension's owned commands, + keymaps, event listeners, styles, themes and syntax registrations and + removes the active record before returning the structured error. A later + load of the same name starts from a clean lifecycle state. +- Unloading an unknown name returns `false` and performs no work. +- Manager shutdown removes all active owner-scoped contributions and clears + the active set without invoking extension `unload()` hooks. Shutdown is + cleanup, not a sequence of user-visible explicit unload operations. ## Capability facade