From e4e1fe63f19dc636b43382125d873a96adecaaad Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 16 Jun 2026 05:07:57 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(canvas-core):=20Lumens=20(Live=20Inter?= =?UTF-8?q?activity)=20protocol=201.1=20=E2=80=94=20schemas,=20LX=20interp?= =?UTF-8?q?reter,=20capability=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omadia-canvas-protocol/1.1, additive over 1.0 (PR byte5ai/omadia-ui#31, issue #34). - L0: lx-ast/scene/ports-wires/capability-manifest/lumen JSON schemas (+ var read node, get projection node) with accept/reject conformance fixtures. - L1: deterministic, gas+depth+value-size-bounded, no-eval LX interpreter with seeded random/now and a static semantic validator. Prototype-pollution hardened; all 10 findings from an adversarial (Forge/GPT-5.4) review fixed. - L5/L7/L8 Tier-2 policy (pure, consumed by the orchestrator when built): effect classification, broker egress bounds (rate/quota/in-flight/idempotency/ backpressure), content-addressed never-stale asset cache, import consent, content-addressed presets + resolve-then-generate + fork lineage, share token re-mint (assets travel by id, never the author token). - validateLumenFull combined gate. 172 tests, tsc clean. Land this before the omadia-ui UI PR (UI syncs these schemas). --- .../fixtures/lumen/accept/counter.json | 7 + .../fixtures/lumen/accept/data-map.json | 15 + .../fixtures/lumen/accept/game-tick.json | 19 + .../fixtures/lumen/accept/grid-board.json | 8 + .../fixtures/lumen/reject/bad-capability.json | 1 + .../fixtures/lumen/reject/bad-preset-id.json | 1 + .../fixtures/lumen/reject/bad-state-leaf.json | 1 + .../fixtures/lumen/reject/bad-view-node.json | 1 + .../fixtures/lumen/reject/extra-toplevel.json | 1 + .../fixtures/lumen/reject/missing-view.json | 1 + .../fixtures/lumen/reject/tick-too-fast.json | 1 + .../fixtures/lumen/reject/unknown-event.json | 1 + .../canvas-core/fixtures/lx/accept/arith.json | 1 + .../fixtures/lx/accept/call-map.json | 1 + .../fixtures/lx/accept/get-list-index.json | 1 + .../fixtures/lx/accept/get-record-field.json | 1 + .../fixtures/lx/accept/grid-read.json | 1 + .../fixtures/lx/accept/if-compare.json | 1 + .../fixtures/lx/accept/let-binding.json | 1 + .../fixtures/lx/accept/map-fold-var.json | 5 + .../canvas-core/fixtures/lx/accept/match.json | 1 + .../fixtures/lx/accept/record-ctor.json | 1 + .../fixtures/lx/accept/set-functional.json | 1 + .../fixtures/lx/reject/div-arity.json | 1 + .../fixtures/lx/reject/eval-call.json | 1 + .../fixtures/lx/reject/extra-prop.json | 1 + .../fixtures/lx/reject/get-missing-key.json | 1 + .../fixtures/lx/reject/if-missing-else.json | 1 + .../fixtures/lx/reject/raw-source.json | 1 + .../fixtures/lx/reject/two-ops.json | 1 + .../fixtures/lx/reject/while-loop.json | 1 + .../fixtures/scene/accept/minimal.json | 1 + .../fixtures/scene/accept/shapes.json | 12 + .../fixtures/scene/reject/extra-prop.json | 1 + .../fixtures/scene/reject/freeform-color.json | 1 + .../fixtures/scene/reject/missing-dims.json | 1 + .../fixtures/scene/reject/unknown-kind.json | 1 + .../fixtures/scene/reject/wrong-type.json | 1 + .../schema/capability-manifest.schema.json | 38 ++ .../canvas-core/schema/lumen.schema.json | 96 +++++ .../canvas-core/schema/lx-ast.schema.json | 126 ++++++ .../schema/ports-wires.schema.json | 52 +++ .../canvas-core/schema/scene.schema.json | 138 ++++++ .../src/capabilities/assetCache.ts | 84 ++++ .../canvas-core/src/capabilities/broker.ts | 101 +++++ .../canvas-core/src/capabilities/consent.ts | 61 +++ .../canvas-core/src/capabilities/effects.ts | 75 ++++ .../canvas-core/src/capabilities/index.ts | 8 + .../canvas-core/src/capabilities/presets.ts | 109 +++++ .../canvas-core/src/capabilities/sharing.ts | 115 +++++ middleware/packages/canvas-core/src/index.ts | 14 +- .../packages/canvas-core/src/lx/index.ts | 3 + .../canvas-core/src/lx/interpreter.ts | 404 ++++++++++++++++++ .../packages/canvas-core/src/lx/types.ts | 76 ++++ .../packages/canvas-core/src/lx/validate.ts | 125 ++++++ .../packages/canvas-core/src/validator.ts | 35 ++ .../src/validators.generated.d.mts | 4 + .../canvas-core/test/capabilities.test.ts | 114 +++++ .../packages/canvas-core/test/lumen.test.ts | 69 +++ .../canvas-core/test/lx-interpreter.test.ts | 222 ++++++++++ .../packages/canvas-core/test/presets.test.ts | 60 +++ .../packages/canvas-core/test/sharing.test.ts | 73 ++++ .../canvas-core/tools/genValidator.ts | 10 +- 63 files changed, 2308 insertions(+), 2 deletions(-) create mode 100644 middleware/packages/canvas-core/fixtures/lumen/accept/counter.json create mode 100644 middleware/packages/canvas-core/fixtures/lumen/accept/data-map.json create mode 100644 middleware/packages/canvas-core/fixtures/lumen/accept/game-tick.json create mode 100644 middleware/packages/canvas-core/fixtures/lumen/accept/grid-board.json create mode 100644 middleware/packages/canvas-core/fixtures/lumen/reject/bad-capability.json create mode 100644 middleware/packages/canvas-core/fixtures/lumen/reject/bad-preset-id.json create mode 100644 middleware/packages/canvas-core/fixtures/lumen/reject/bad-state-leaf.json create mode 100644 middleware/packages/canvas-core/fixtures/lumen/reject/bad-view-node.json create mode 100644 middleware/packages/canvas-core/fixtures/lumen/reject/extra-toplevel.json create mode 100644 middleware/packages/canvas-core/fixtures/lumen/reject/missing-view.json create mode 100644 middleware/packages/canvas-core/fixtures/lumen/reject/tick-too-fast.json create mode 100644 middleware/packages/canvas-core/fixtures/lumen/reject/unknown-event.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/accept/arith.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/accept/call-map.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/accept/get-list-index.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/accept/get-record-field.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/accept/grid-read.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/accept/if-compare.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/accept/let-binding.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/accept/map-fold-var.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/accept/match.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/accept/record-ctor.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/accept/set-functional.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/reject/div-arity.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/reject/eval-call.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/reject/extra-prop.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/reject/get-missing-key.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/reject/if-missing-else.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/reject/raw-source.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/reject/two-ops.json create mode 100644 middleware/packages/canvas-core/fixtures/lx/reject/while-loop.json create mode 100644 middleware/packages/canvas-core/fixtures/scene/accept/minimal.json create mode 100644 middleware/packages/canvas-core/fixtures/scene/accept/shapes.json create mode 100644 middleware/packages/canvas-core/fixtures/scene/reject/extra-prop.json create mode 100644 middleware/packages/canvas-core/fixtures/scene/reject/freeform-color.json create mode 100644 middleware/packages/canvas-core/fixtures/scene/reject/missing-dims.json create mode 100644 middleware/packages/canvas-core/fixtures/scene/reject/unknown-kind.json create mode 100644 middleware/packages/canvas-core/fixtures/scene/reject/wrong-type.json create mode 100644 middleware/packages/canvas-core/schema/capability-manifest.schema.json create mode 100644 middleware/packages/canvas-core/schema/lumen.schema.json create mode 100644 middleware/packages/canvas-core/schema/lx-ast.schema.json create mode 100644 middleware/packages/canvas-core/schema/ports-wires.schema.json create mode 100644 middleware/packages/canvas-core/schema/scene.schema.json create mode 100644 middleware/packages/canvas-core/src/capabilities/assetCache.ts create mode 100644 middleware/packages/canvas-core/src/capabilities/broker.ts create mode 100644 middleware/packages/canvas-core/src/capabilities/consent.ts create mode 100644 middleware/packages/canvas-core/src/capabilities/effects.ts create mode 100644 middleware/packages/canvas-core/src/capabilities/index.ts create mode 100644 middleware/packages/canvas-core/src/capabilities/presets.ts create mode 100644 middleware/packages/canvas-core/src/capabilities/sharing.ts create mode 100644 middleware/packages/canvas-core/src/lx/index.ts create mode 100644 middleware/packages/canvas-core/src/lx/interpreter.ts create mode 100644 middleware/packages/canvas-core/src/lx/types.ts create mode 100644 middleware/packages/canvas-core/src/lx/validate.ts create mode 100644 middleware/packages/canvas-core/test/capabilities.test.ts create mode 100644 middleware/packages/canvas-core/test/lumen.test.ts create mode 100644 middleware/packages/canvas-core/test/lx-interpreter.test.ts create mode 100644 middleware/packages/canvas-core/test/presets.test.ts create mode 100644 middleware/packages/canvas-core/test/sharing.test.ts diff --git a/middleware/packages/canvas-core/fixtures/lumen/accept/counter.json b/middleware/packages/canvas-core/fixtures/lumen/accept/counter.json new file mode 100644 index 00000000..fefe195e --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lumen/accept/counter.json @@ -0,0 +1,7 @@ +{ + "type": "lumen", "id": "counter", + "state": { "count": { "type": "int", "min": 0, "init": 0 } }, + "transitions": { "inc": { "set": { "count": { "+": [ { "state": "count" }, { "lit": 1 } ] } } } }, + "view": { "lit": { "type": "text", "content": "tap to count" } }, + "events": [ { "on": "tap", "run": "inc" } ] +} diff --git a/middleware/packages/canvas-core/fixtures/lumen/accept/data-map.json b/middleware/packages/canvas-core/fixtures/lumen/accept/data-map.json new file mode 100644 index 00000000..8e38b250 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lumen/accept/data-map.json @@ -0,0 +1,15 @@ +{ + "type": "lumen", "id": "map-viz", + "state": { "tiles": { "type": "dataRef" }, "sel": { "type": "string", "maxLength": 64, "init": "" } }, + "transitions": { "pick": { "set": { "sel": { "event": "id" } } } }, + "view": { "lit": { "type": "scene", "width": 256, "height": 256, "draw": [] } }, + "events": [ { "on": "tap", "run": "pick" } ], + "cadence": "reactive", + "capabilities": [ + { "cap": "loadData", "scope": { "dataRef": "tiles" } }, + { "cap": "tiles", "effect": "internal", "scope": { "provider": "osm" } } + ], + "ports": [ { "name": "selection", "dir": "out", "type": "selection" } ], + "expose": [ { "name": "selection", "type": "selection" } ], + "preset": { "id": "preset-0123456789abcdef", "parent": "preset-fedcba9876543210", "params": { "zoom": 4 } } +} diff --git a/middleware/packages/canvas-core/fixtures/lumen/accept/game-tick.json b/middleware/packages/canvas-core/fixtures/lumen/accept/game-tick.json new file mode 100644 index 00000000..52d286c0 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lumen/accept/game-tick.json @@ -0,0 +1,19 @@ +{ + "type": "lumen", "id": "pong", + "state": { + "x": { "type": "number", "min": 0, "max": 320, "init": 16 }, + "vx": { "type": "number", "init": 2 }, + "score": { "type": "int", "min": 0, "init": 0 }, + "mode": { "type": "enum", "values": [ "run", "over" ], "init": "run" } + }, + "transitions": { + "step": { "set": { "x": { "+": [ { "state": "x" }, { "state": "vx" } ] } } }, + "left": { "set": { "vx": { "-": [ { "lit": 0 }, { "lit": 2 } ] } } } + }, + "view": { "lit": { "type": "scene", "width": 320, "height": 200, "draw": [ { "kind": "circle", "cx": 16, "cy": 100, "r": 8, "fill": "accent" } ] } }, + "events": [ + { "on": "tick", "rate": 60, "run": "step" }, + { "on": "key", "key": "ArrowLeft", "run": "left" } + ], + "cadence": { "tick": 60 } +} diff --git a/middleware/packages/canvas-core/fixtures/lumen/accept/grid-board.json b/middleware/packages/canvas-core/fixtures/lumen/accept/grid-board.json new file mode 100644 index 00000000..7583ac87 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lumen/accept/grid-board.json @@ -0,0 +1,8 @@ +{ + "type": "lumen", "id": "life", + "state": { "board": { "type": "grid", "w": 16, "h": 16, "of": { "type": "bool", "init": false } } }, + "transitions": { "toggle": { "set": { "board": { "lit": true } } } }, + "view": { "lit": { "type": "scene", "width": 256, "height": 256, "draw": [] } }, + "events": [ { "on": "tap", "run": "toggle" }, { "on": "tick", "rate": 8, "run": "toggle" } ], + "cadence": { "tick": 8 } +} diff --git a/middleware/packages/canvas-core/fixtures/lumen/reject/bad-capability.json b/middleware/packages/canvas-core/fixtures/lumen/reject/bad-capability.json new file mode 100644 index 00000000..512ba25f --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lumen/reject/bad-capability.json @@ -0,0 +1 @@ +{ "type": "lumen", "id": "x", "state": {}, "transitions": {}, "view": { "lit": 1 }, "events": [], "capabilities": [ { "cap": "exec" } ] } diff --git a/middleware/packages/canvas-core/fixtures/lumen/reject/bad-preset-id.json b/middleware/packages/canvas-core/fixtures/lumen/reject/bad-preset-id.json new file mode 100644 index 00000000..554502c5 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lumen/reject/bad-preset-id.json @@ -0,0 +1 @@ +{ "type": "lumen", "id": "x", "state": {}, "transitions": {}, "view": { "lit": 1 }, "events": [], "preset": { "id": "my-preset" } } diff --git a/middleware/packages/canvas-core/fixtures/lumen/reject/bad-state-leaf.json b/middleware/packages/canvas-core/fixtures/lumen/reject/bad-state-leaf.json new file mode 100644 index 00000000..4c519e8e --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lumen/reject/bad-state-leaf.json @@ -0,0 +1 @@ +{ "type": "lumen", "id": "x", "state": { "n": { "type": "int" } }, "transitions": {}, "view": { "lit": 1 }, "events": [] } diff --git a/middleware/packages/canvas-core/fixtures/lumen/reject/bad-view-node.json b/middleware/packages/canvas-core/fixtures/lumen/reject/bad-view-node.json new file mode 100644 index 00000000..a13a7694 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lumen/reject/bad-view-node.json @@ -0,0 +1 @@ +{ "type": "lumen", "id": "x", "state": {}, "transitions": {}, "view": { "while": [ { "lit": 1 } ] }, "events": [] } diff --git a/middleware/packages/canvas-core/fixtures/lumen/reject/extra-toplevel.json b/middleware/packages/canvas-core/fixtures/lumen/reject/extra-toplevel.json new file mode 100644 index 00000000..4bd30814 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lumen/reject/extra-toplevel.json @@ -0,0 +1 @@ +{ "type": "lumen", "id": "x", "state": {}, "transitions": {}, "view": { "lit": 1 }, "events": [], "script": "alert(1)" } diff --git a/middleware/packages/canvas-core/fixtures/lumen/reject/missing-view.json b/middleware/packages/canvas-core/fixtures/lumen/reject/missing-view.json new file mode 100644 index 00000000..e2bbf088 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lumen/reject/missing-view.json @@ -0,0 +1 @@ +{ "type": "lumen", "id": "x", "state": {}, "transitions": {}, "events": [] } diff --git a/middleware/packages/canvas-core/fixtures/lumen/reject/tick-too-fast.json b/middleware/packages/canvas-core/fixtures/lumen/reject/tick-too-fast.json new file mode 100644 index 00000000..527cce6a --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lumen/reject/tick-too-fast.json @@ -0,0 +1 @@ +{ "type": "lumen", "id": "x", "state": {}, "transitions": {}, "view": { "lit": 1 }, "events": [ { "on": "tick", "rate": 120, "run": "step" } ] } diff --git a/middleware/packages/canvas-core/fixtures/lumen/reject/unknown-event.json b/middleware/packages/canvas-core/fixtures/lumen/reject/unknown-event.json new file mode 100644 index 00000000..e99f7cb7 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lumen/reject/unknown-event.json @@ -0,0 +1 @@ +{ "type": "lumen", "id": "x", "state": {}, "transitions": {}, "view": { "lit": 1 }, "events": [ { "on": "scroll", "run": "step" } ] } diff --git a/middleware/packages/canvas-core/fixtures/lx/accept/arith.json b/middleware/packages/canvas-core/fixtures/lx/accept/arith.json new file mode 100644 index 00000000..dab87d08 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/accept/arith.json @@ -0,0 +1 @@ +{ "+": [ { "lit": 1 }, { "state": "score" } ] } diff --git a/middleware/packages/canvas-core/fixtures/lx/accept/call-map.json b/middleware/packages/canvas-core/fixtures/lx/accept/call-map.json new file mode 100644 index 00000000..d9c87036 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/accept/call-map.json @@ -0,0 +1 @@ +{ "call": "map", "args": [ { "state": "cells" }, { "call": "clamp", "args": [ { "event": "v" }, { "lit": 0 }, { "lit": 9 } ] } ] } diff --git a/middleware/packages/canvas-core/fixtures/lx/accept/get-list-index.json b/middleware/packages/canvas-core/fixtures/lx/accept/get-list-index.json new file mode 100644 index 00000000..78faa3ae --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/accept/get-list-index.json @@ -0,0 +1 @@ +{ "call": "map", "args": [ { "state": "rows" }, { "get": { "var": "it" }, "key": { "lit": "label" } } ] } diff --git a/middleware/packages/canvas-core/fixtures/lx/accept/get-record-field.json b/middleware/packages/canvas-core/fixtures/lx/accept/get-record-field.json new file mode 100644 index 00000000..c6616931 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/accept/get-record-field.json @@ -0,0 +1 @@ +{ "get": { "record": { "x": { "lit": 10 }, "y": { "lit": 20 } } }, "key": { "lit": "x" } } diff --git a/middleware/packages/canvas-core/fixtures/lx/accept/grid-read.json b/middleware/packages/canvas-core/fixtures/lx/accept/grid-read.json new file mode 100644 index 00000000..80b3c7b5 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/accept/grid-read.json @@ -0,0 +1 @@ +{ "state": "board", "at": [ { "event": "x" }, { "event": "y" } ] } diff --git a/middleware/packages/canvas-core/fixtures/lx/accept/if-compare.json b/middleware/packages/canvas-core/fixtures/lx/accept/if-compare.json new file mode 100644 index 00000000..986e714b --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/accept/if-compare.json @@ -0,0 +1 @@ +{ "if": { ">": [ { "state": "x" }, { "lit": 0 } ] }, "then": { "lit": "pos" }, "else": { "lit": "nonpos" } } diff --git a/middleware/packages/canvas-core/fixtures/lx/accept/let-binding.json b/middleware/packages/canvas-core/fixtures/lx/accept/let-binding.json new file mode 100644 index 00000000..1c46aa29 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/accept/let-binding.json @@ -0,0 +1 @@ +{ "let": { "n": { "state": "count" } }, "in": { "*": [ { "lit": 2 }, { "lit": 3 } ] } } diff --git a/middleware/packages/canvas-core/fixtures/lx/accept/map-fold-var.json b/middleware/packages/canvas-core/fixtures/lx/accept/map-fold-var.json new file mode 100644 index 00000000..dab075b7 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/accept/map-fold-var.json @@ -0,0 +1,5 @@ +{ "call": "fold", "args": [ + { "call": "map", "args": [ { "state": "cells" }, { "*": [ { "var": "it" }, { "lit": 2 } ] } ] }, + { "lit": 0 }, + { "+": [ { "var": "acc" }, { "var": "it" } ] } +] } diff --git a/middleware/packages/canvas-core/fixtures/lx/accept/match.json b/middleware/packages/canvas-core/fixtures/lx/accept/match.json new file mode 100644 index 00000000..5cb84cdc --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/accept/match.json @@ -0,0 +1 @@ +{ "match": { "state": "mode" }, "cases": [ { "when": { "lit": "run" }, "then": { "lit": 1 } } ], "else": { "lit": 0 } } diff --git a/middleware/packages/canvas-core/fixtures/lx/accept/record-ctor.json b/middleware/packages/canvas-core/fixtures/lx/accept/record-ctor.json new file mode 100644 index 00000000..4235a9e3 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/accept/record-ctor.json @@ -0,0 +1 @@ +{ "record": { "x": { "lit": 1 }, "y": { "state": "y" } } } diff --git a/middleware/packages/canvas-core/fixtures/lx/accept/set-functional.json b/middleware/packages/canvas-core/fixtures/lx/accept/set-functional.json new file mode 100644 index 00000000..7f9263e7 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/accept/set-functional.json @@ -0,0 +1 @@ +{ "set": { "score": { "+": [ { "state": "score" }, { "lit": 1 } ] } } } diff --git a/middleware/packages/canvas-core/fixtures/lx/reject/div-arity.json b/middleware/packages/canvas-core/fixtures/lx/reject/div-arity.json new file mode 100644 index 00000000..a0447701 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/reject/div-arity.json @@ -0,0 +1 @@ +{ "/": [ { "lit": 1 } ] } diff --git a/middleware/packages/canvas-core/fixtures/lx/reject/eval-call.json b/middleware/packages/canvas-core/fixtures/lx/reject/eval-call.json new file mode 100644 index 00000000..07a78a58 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/reject/eval-call.json @@ -0,0 +1 @@ +{ "call": "eval", "args": [ { "lit": "1+1" } ] } diff --git a/middleware/packages/canvas-core/fixtures/lx/reject/extra-prop.json b/middleware/packages/canvas-core/fixtures/lx/reject/extra-prop.json new file mode 100644 index 00000000..6211773c --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/reject/extra-prop.json @@ -0,0 +1 @@ +{ "lit": 1, "sneaky": 2 } diff --git a/middleware/packages/canvas-core/fixtures/lx/reject/get-missing-key.json b/middleware/packages/canvas-core/fixtures/lx/reject/get-missing-key.json new file mode 100644 index 00000000..e8cbea22 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/reject/get-missing-key.json @@ -0,0 +1 @@ +{ "get": { "lit": [1,2,3] } } diff --git a/middleware/packages/canvas-core/fixtures/lx/reject/if-missing-else.json b/middleware/packages/canvas-core/fixtures/lx/reject/if-missing-else.json new file mode 100644 index 00000000..c2135a06 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/reject/if-missing-else.json @@ -0,0 +1 @@ +{ "if": { "lit": true }, "then": { "lit": 1 } } diff --git a/middleware/packages/canvas-core/fixtures/lx/reject/raw-source.json b/middleware/packages/canvas-core/fixtures/lx/reject/raw-source.json new file mode 100644 index 00000000..3b822cb2 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/reject/raw-source.json @@ -0,0 +1 @@ +"state.score + 1" diff --git a/middleware/packages/canvas-core/fixtures/lx/reject/two-ops.json b/middleware/packages/canvas-core/fixtures/lx/reject/two-ops.json new file mode 100644 index 00000000..06130e58 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/reject/two-ops.json @@ -0,0 +1 @@ +{ "+": [ { "lit": 1 }, { "lit": 2 } ], "-": [ { "lit": 3 }, { "lit": 4 } ] } diff --git a/middleware/packages/canvas-core/fixtures/lx/reject/while-loop.json b/middleware/packages/canvas-core/fixtures/lx/reject/while-loop.json new file mode 100644 index 00000000..49185186 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/lx/reject/while-loop.json @@ -0,0 +1 @@ +{ "while": [ { "lit": true }, { "lit": 1 } ] } diff --git a/middleware/packages/canvas-core/fixtures/scene/accept/minimal.json b/middleware/packages/canvas-core/fixtures/scene/accept/minimal.json new file mode 100644 index 00000000..bb0d601c --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/scene/accept/minimal.json @@ -0,0 +1 @@ +{ "type": "scene", "width": 10, "height": 10, "draw": [] } diff --git a/middleware/packages/canvas-core/fixtures/scene/accept/shapes.json b/middleware/packages/canvas-core/fixtures/scene/accept/shapes.json new file mode 100644 index 00000000..205139a9 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/scene/accept/shapes.json @@ -0,0 +1,12 @@ +{ "type": "scene", "id": "s1", "width": 320, "height": 200, + "camera": { "x": 0, "y": 0, "zoom": 1 }, + "draw": [ + { "kind": "rect", "x": 8, "y": 8, "w": 64, "h": 32, "r": 6, "fill": "surface-raised", "stroke": "accent", "strokeW": 2, "id": "card" }, + { "kind": "circle", "cx": 160, "cy": 100, "r": 24, "fill": "accent.glow", "id": "ball" }, + { "kind": "line", "x1": 0, "y1": 0, "x2": 320, "y2": 200, "stroke": "text-faint" }, + { "kind": "path", "points": [ [0,0], [10,20], [20,0] ], "closed": true, "fill": "success" }, + { "kind": "text", "x": 12, "y": 24, "text": "Score 42", "size": 14, "weight": 600, "register": "mono", "fill": "text", "id": "score" }, + { "kind": "group", "transform": { "x": 100, "y": 100, "scale": 1.5, "rotate": 0 }, "children": [ + { "kind": "rect", "x": 0, "y": 0, "w": 8, "h": 8, "fill": "danger" } + ] } + ] } diff --git a/middleware/packages/canvas-core/fixtures/scene/reject/extra-prop.json b/middleware/packages/canvas-core/fixtures/scene/reject/extra-prop.json new file mode 100644 index 00000000..cf88d30e --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/scene/reject/extra-prop.json @@ -0,0 +1 @@ +{ "type": "scene", "width": 10, "height": 10, "draw": [ { "kind": "circle", "cx": 1, "cy": 1, "r": 1, "onClick": "x" } ] } diff --git a/middleware/packages/canvas-core/fixtures/scene/reject/freeform-color.json b/middleware/packages/canvas-core/fixtures/scene/reject/freeform-color.json new file mode 100644 index 00000000..31b89d06 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/scene/reject/freeform-color.json @@ -0,0 +1 @@ +{ "type": "scene", "width": 10, "height": 10, "draw": [ { "kind": "rect", "x": 0, "y": 0, "w": 1, "h": 1, "fill": "#ff0000" } ] } diff --git a/middleware/packages/canvas-core/fixtures/scene/reject/missing-dims.json b/middleware/packages/canvas-core/fixtures/scene/reject/missing-dims.json new file mode 100644 index 00000000..838aa9d4 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/scene/reject/missing-dims.json @@ -0,0 +1 @@ +{ "type": "scene", "draw": [] } diff --git a/middleware/packages/canvas-core/fixtures/scene/reject/unknown-kind.json b/middleware/packages/canvas-core/fixtures/scene/reject/unknown-kind.json new file mode 100644 index 00000000..6c8db3fb --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/scene/reject/unknown-kind.json @@ -0,0 +1 @@ +{ "type": "scene", "width": 10, "height": 10, "draw": [ { "kind": "polygon", "points": [ [0,0] ] } ] } diff --git a/middleware/packages/canvas-core/fixtures/scene/reject/wrong-type.json b/middleware/packages/canvas-core/fixtures/scene/reject/wrong-type.json new file mode 100644 index 00000000..deb15cc9 --- /dev/null +++ b/middleware/packages/canvas-core/fixtures/scene/reject/wrong-type.json @@ -0,0 +1 @@ +{ "type": "canvas", "width": 10, "height": 10, "draw": [] } diff --git a/middleware/packages/canvas-core/schema/capability-manifest.schema.json b/middleware/packages/canvas-core/schema/capability-manifest.schema.json new file mode 100644 index 00000000..68246e03 --- /dev/null +++ b/middleware/packages/canvas-core/schema/capability-manifest.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://omadia.ai/protocol/1.1/capability-manifest.schema.json", + "title": "Capability manifest — the mediated doors", + "description": "omadia-canvas-protocol/1.1 (lumens-spec.md §6): default-deny. Each capability is declared, effect-classified (local/internal/external-effect), granted by Tier 2, and BROKERED — a Lumen never performs the effect directly. The agent owns capability REQUESTS; grants are Tier-2 policy + user consent (§0.5) — a patch can ask, never self-grant. Imported/shared Lumens surface this manifest for consent before first run.", + + "$ref": "#/$defs/capabilityList", + + "$defs": { + "capabilityName": { + "description": "lumens-spec.md §6 catalog", + "enum": ["persist", "loadData", "writeData", "tiles", "fetch", "generateAsset", "clipboard", "share", "savePreset"] + }, + + "effectClass": { + "description": "CONCEPT.md §Security Surface effect classes. Egress carrying state/DataRef-derived data is external-effect (per-call confirmation) unless pre-approved at grant (§6).", + "enum": ["local", "internal", "external-effect"] + }, + + "capabilityRequest": { + "type": "object", "additionalProperties": false, "required": ["cap"], + "properties": { + "cap": { "$ref": "#/$defs/capabilityName" }, + "effect": { "$ref": "#/$defs/effectClass", "description": "agent's declared/expected class; Tier 2 re-derives and may upgrade (never downgrade)" }, + "scope": { + "type": "object", + "description": "per-capability scope (spike-tunable). e.g. {namespace} for persist; {dataRef} for loadData; {endpoints:[...], shape} for fetch; {provider} for tiles; {kind} for generateAsset; {writeCapabilities} for writeData.", + "additionalProperties": true + } + } + }, + + "capabilityList": { + "type": "array", + "items": { "$ref": "#/$defs/capabilityRequest" } + } + } +} diff --git a/middleware/packages/canvas-core/schema/lumen.schema.json b/middleware/packages/canvas-core/schema/lumen.schema.json new file mode 100644 index 00000000..fa61bca3 --- /dev/null +++ b/middleware/packages/canvas-core/schema/lumen.schema.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://omadia.ai/protocol/1.1/lumen.schema.json", + "title": "Lumen — Live Interactivity unit", + "description": "omadia-canvas-protocol/1.1 (lumens-spec.md §1): a self-contained interactive unit on the canvas — declarative DATA, not code. Delivered as tree content inside an ordinary surface_snapshot/surface_patch (no new transport). A Lumen is valid iff its state conforms to §1.1, every LXNode passes the §2 AST whitelist + static bounds check, every EventBinding names a declared transition + §4 event, every CapabilityRequest names a §6 catalog capability, and every PortSpec/ExposeSpec is §7-typed. Any failure ⇒ rejected wholesale with surface_error (it never partially renders). This schema enforces the STRUCTURAL contract; the L1 static validator enforces semantics JSON Schema cannot (path resolution, gas/iteration bounds, transition/event coherence).", + + "$ref": "#/$defs/lumen", + + "$defs": { + "lumen": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id", "state", "transitions", "view", "events"], + "properties": { + "type": { "const": "lumen" }, + "id": { "type": "string", "description": "stable; patches/wires/beam target it" }, + "state": { "$ref": "#/$defs/stateSchema" }, + "transitions": { + "type": "object", + "description": "TransitionName → pure (state,event)->state LX expression", + "additionalProperties": { "$ref": "https://omadia.ai/protocol/1.1/lx-ast.schema.json" } + }, + "view": { + "$ref": "https://omadia.ai/protocol/1.1/lx-ast.schema.json", + "description": "pure state -> primitive/scene tree (an LX expression; a static view is {lit: })" + }, + "events": { "type": "array", "items": { "$ref": "#/$defs/eventBinding" } }, + "cadence": { "$ref": "#/$defs/cadenceSpec" }, + "capabilities": { "$ref": "https://omadia.ai/protocol/1.1/capability-manifest.schema.json#/$defs/capabilityList" }, + "ports": { "$ref": "https://omadia.ai/protocol/1.1/ports-wires.schema.json#/$defs/portsList" }, + "expose": { "$ref": "https://omadia.ai/protocol/1.1/ports-wires.schema.json#/$defs/exposeList" }, + "preset": { "$ref": "#/$defs/presetRef" } + } + }, + + "stateSchema": { + "type": "object", + "description": "a typed, CLOSED record (§1.1). Total serialised size is capped (default 256 KB, spike-tunable) — enforced by L1.", + "additionalProperties": { "$ref": "#/$defs/stateLeaf" } + }, + + "stateLeaf": { + "type": "object", + "oneOf": [ + { "additionalProperties": false, "required": ["type", "init"], + "properties": { "type": { "enum": ["int", "number"] }, "min": { "type": "number" }, "max": { "type": "number" }, "init": { "type": "number" } } }, + { "additionalProperties": false, "required": ["type", "init"], + "properties": { "type": { "const": "bool" }, "init": { "type": "boolean" } } }, + { "additionalProperties": false, "required": ["type", "maxLength", "init"], + "properties": { "type": { "const": "string" }, "maxLength": { "type": "integer", "minimum": 0 }, "init": { "type": "string" } } }, + { "additionalProperties": false, "required": ["type", "values", "init"], + "properties": { "type": { "const": "enum" }, "values": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "init": { "type": "string" } } }, + { "additionalProperties": false, "required": ["type", "of", "maxLen", "init"], + "properties": { "type": { "const": "list" }, "of": { "$ref": "#/$defs/stateLeaf" }, "maxLen": { "type": "integer", "minimum": 0 }, "init": { "type": "array" } } }, + { "additionalProperties": false, "required": ["type", "fields", "init"], + "properties": { "type": { "const": "record" }, "fields": { "$ref": "#/$defs/stateSchema" }, "init": { "type": "object" } } }, + { "additionalProperties": false, "required": ["type", "w", "h", "of"], + "properties": { "type": { "const": "grid" }, "w": { "type": "integer", "minimum": 1 }, "h": { "type": "integer", "minimum": 1 }, "of": { "$ref": "#/$defs/stateLeaf" }, "init": {} } }, + { "additionalProperties": false, "required": ["type"], + "properties": { "type": { "const": "dataRef" }, "init": { "$ref": "https://omadia.ai/protocol/1.0/data-ref.schema.json" } } } + ] + }, + + "eventBinding": { + "type": "object", "additionalProperties": false, "required": ["on", "run"], + "properties": { + "on": { "enum": ["tap", "longPress", "drag", "pinch", "swipe", "pointerMove", "key", "tick", "timer", "wire"] }, + "target": { "$ref": "https://omadia.ai/protocol/1.0/target-ref.schema.json" }, + "key": { "type": "string", "description": "for 'key' — declared keys only (e.g. 'ArrowLeft','Space')" }, + "rate": { "type": "number", "exclusiveMinimum": 0, "maximum": 60, "description": "for 'tick' — declared, capped ≤60 Hz (§5)" }, + "everyMs": { "type": "number", "exclusiveMinimum": 0, "description": "for 'timer' — enforced minimum period; combined tick+timer wakeup budget is capped (§4)" }, + "captureLongPress": { "type": "boolean", "description": "claim longPress over host context-invoke (§4)" }, + "run": { "type": "string", "description": "the transition to evaluate" } + } + }, + + "cadenceSpec": { + "description": "declared per node/region, not globally (§5). default 'reactive'.", + "oneOf": [ + { "enum": ["static", "reactive"] }, + { "type": "object", "additionalProperties": false, "required": ["tick"], + "properties": { "tick": { "type": "number", "exclusiveMinimum": 0, "maximum": 60, "description": "Hz, ≤60" } } } + ] + }, + + "presetRef": { + "type": "object", "additionalProperties": false, "required": ["id"], + "description": "provenance if instantiated/forked (§8)", + "properties": { + "id": { "type": "string", "pattern": "^preset-[0-9a-f]{16}$", "description": "content-addressed preset-" }, + "parent": { "type": "string", "pattern": "^preset-[0-9a-f]{16}$", "description": "fork lineage" }, + "params": { "type": "object", "additionalProperties": true } + } + } + } +} diff --git a/middleware/packages/canvas-core/schema/lx-ast.schema.json b/middleware/packages/canvas-core/schema/lx-ast.schema.json new file mode 100644 index 00000000..edf3e520 --- /dev/null +++ b/middleware/packages/canvas-core/schema/lx-ast.schema.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://omadia.ai/protocol/1.1/lx-ast.schema.json", + "title": "Lume Expressions (LX) — JSON AST", + "description": "omadia-canvas-protocol/1.1 (lumens-spec.md §2): the pure, total expression language of a Lumen's `transitions` and `view`, delivered as a JSON AST (never source text). This schema is the STRUCTURAL whitelist — every node must be one of the §2.2 forms and every `call` target one of the §2.3 std-lib names. Semantic guarantees that JSON Schema cannot express (gas ceiling, bounded iteration, state/event path resolution, totality of `if`/`match`) are enforced by the L1 static validator + interpreter. Unknown node shapes are hard-rejected (whitelist parser discipline, §0.1).", + + "$ref": "#/$defs/lxNode", + + "$defs": { + "lxNode": { + "type": "object", + "oneOf": [ + { "$ref": "#/$defs/lx_lit" }, + { "$ref": "#/$defs/lx_state" }, + { "$ref": "#/$defs/lx_event" }, + { "$ref": "#/$defs/lx_var" }, + { "$ref": "#/$defs/lx_let" }, + { "$ref": "#/$defs/lx_add" }, + { "$ref": "#/$defs/lx_sub" }, + { "$ref": "#/$defs/lx_mul" }, + { "$ref": "#/$defs/lx_div" }, + { "$ref": "#/$defs/lx_mod" }, + { "$ref": "#/$defs/lx_gt" }, + { "$ref": "#/$defs/lx_gte" }, + { "$ref": "#/$defs/lx_lt" }, + { "$ref": "#/$defs/lx_lte" }, + { "$ref": "#/$defs/lx_eq" }, + { "$ref": "#/$defs/lx_neq" }, + { "$ref": "#/$defs/lx_and" }, + { "$ref": "#/$defs/lx_or" }, + { "$ref": "#/$defs/lx_not" }, + { "$ref": "#/$defs/lx_if" }, + { "$ref": "#/$defs/lx_match" }, + { "$ref": "#/$defs/lx_record" }, + { "$ref": "#/$defs/lx_list" }, + { "$ref": "#/$defs/lx_get" }, + { "$ref": "#/$defs/lx_set" }, + { "$ref": "#/$defs/lx_call" } + ] + }, + + "lx_lit": { "type": "object", "required": ["lit"], "additionalProperties": false, + "properties": { "lit": { "description": "any JSON literal: int/number/bool/string/list/record" } } }, + + "lx_state": { "type": "object", "required": ["state"], "additionalProperties": false, + "properties": { + "state": { "type": "string", "description": "dotted path into the declared state schema" }, + "at": { "type": "array", "description": "grid access [x,y]; each an LX expr", "items": { "$ref": "#/$defs/lxNode" }, "minItems": 2, "maxItems": 2 } + } }, + + "lx_event": { "type": "object", "required": ["event"], "additionalProperties": false, + "properties": { "event": { "type": "string", "description": "field of the triggering event" } } }, + + "lx_var": { "type": "object", "required": ["var"], "additionalProperties": false, + "properties": { "var": { "type": "string", "description": "read a lexical binding: a `let` name, or an iteration binding (`it`/`idx`/`acc`) introduced by map/filter/fold (§2.3). Unbound ⇒ reject." } } }, + + "lx_let": { "type": "object", "required": ["let", "in"], "additionalProperties": false, + "properties": { + "let": { "type": "object", "minProperties": 1, "maxProperties": 1, "additionalProperties": { "$ref": "#/$defs/lxNode" }, "description": "single immutable, lexically-scoped binding {name: expr}; read with {var:name}" }, + "in": { "$ref": "#/$defs/lxNode" } + } }, + + "lx_add": { "type": "object", "required": ["+"], "additionalProperties": false, "properties": { "+": { "$ref": "#/$defs/argsNary" } } }, + "lx_sub": { "type": "object", "required": ["-"], "additionalProperties": false, "properties": { "-": { "$ref": "#/$defs/argsNary" } } }, + "lx_mul": { "type": "object", "required": ["*"], "additionalProperties": false, "properties": { "*": { "$ref": "#/$defs/argsNary" } } }, + "lx_div": { "type": "object", "required": ["/"], "additionalProperties": false, "properties": { "/": { "$ref": "#/$defs/argsBinary" } } }, + "lx_mod": { "type": "object", "required": ["mod"], "additionalProperties": false, "properties": { "mod": { "$ref": "#/$defs/argsBinary" } } }, + + "lx_gt": { "type": "object", "required": [">"], "additionalProperties": false, "properties": { ">": { "$ref": "#/$defs/argsBinary" } } }, + "lx_gte": { "type": "object", "required": [">="], "additionalProperties": false, "properties": { ">=": { "$ref": "#/$defs/argsBinary" } } }, + "lx_lt": { "type": "object", "required": ["<"], "additionalProperties": false, "properties": { "<": { "$ref": "#/$defs/argsBinary" } } }, + "lx_lte": { "type": "object", "required": ["<="], "additionalProperties": false, "properties": { "<=": { "$ref": "#/$defs/argsBinary" } } }, + "lx_eq": { "type": "object", "required": ["=="], "additionalProperties": false, "properties": { "==": { "$ref": "#/$defs/argsBinary" } } }, + "lx_neq": { "type": "object", "required": ["!="], "additionalProperties": false, "properties": { "!=": { "$ref": "#/$defs/argsBinary" } } }, + + "lx_and": { "type": "object", "required": ["and"], "additionalProperties": false, "properties": { "and": { "$ref": "#/$defs/argsNary" } } }, + "lx_or": { "type": "object", "required": ["or"], "additionalProperties": false, "properties": { "or": { "$ref": "#/$defs/argsNary" } } }, + "lx_not": { "type": "object", "required": ["not"], "additionalProperties": false, "properties": { "not": { "$ref": "#/$defs/lxNode" } } }, + + "lx_if": { "type": "object", "required": ["if", "then", "else"], "additionalProperties": false, + "properties": { "if": { "$ref": "#/$defs/lxNode" }, "then": { "$ref": "#/$defs/lxNode" }, "else": { "$ref": "#/$defs/lxNode" } } }, + + "lx_match": { "type": "object", "required": ["match", "cases", "else"], "additionalProperties": false, + "properties": { + "match": { "$ref": "#/$defs/lxNode" }, + "cases": { "type": "array", "items": { "type": "object", "required": ["when", "then"], "additionalProperties": false, + "properties": { "when": { "$ref": "#/$defs/lxNode" }, "then": { "$ref": "#/$defs/lxNode" } } } }, + "else": { "$ref": "#/$defs/lxNode" } + } }, + + "lx_record": { "type": "object", "required": ["record"], "additionalProperties": false, + "properties": { "record": { "type": "object", "additionalProperties": { "$ref": "#/$defs/lxNode" } } } }, + + "lx_list": { "type": "object", "required": ["list"], "additionalProperties": false, + "properties": { "list": { "type": "array", "items": { "$ref": "#/$defs/lxNode" } } } }, + + "lx_get": { "type": "object", "required": ["get", "key"], "additionalProperties": false, + "properties": { + "get": { "$ref": "#/$defs/lxNode", "description": "a record or list expression" }, + "key": { "$ref": "#/$defs/lxNode", "description": "a string (record field) or int (list/grid-row index) expression" } + } }, + + "lx_set": { "type": "object", "required": ["set"], "additionalProperties": false, + "properties": { "set": { "type": "object", "minProperties": 1, "additionalProperties": { "$ref": "#/$defs/lxNode" }, "description": "functional update {path: expr} → a NEW state (no mutation)" } } }, + + "lx_call": { "type": "object", "required": ["call", "args"], "additionalProperties": false, + "properties": { + "call": { "$ref": "#/$defs/stdlibName" }, + "args": { "type": "array", "items": { "$ref": "#/$defs/lxNode" } } + } }, + + "argsBinary": { "type": "array", "items": { "$ref": "#/$defs/lxNode" }, "minItems": 2, "maxItems": 2 }, + "argsNary": { "type": "array", "items": { "$ref": "#/$defs/lxNode" }, "minItems": 2 }, + + "stdlibName": { + "description": "lumens-spec.md §2.3 std-lib whitelist (bounded). map/filter/fold/range iterate only over state-bounded collections, making the gas bound static. No `while`, no general recursion. `random`/`now` read host-seeded context.", + "enum": [ + "map", "filter", "fold", "range", "len", "min", "max", "clamp", + "abs", "floor", "ceil", "round", "sqrt", "sign", "pow", + "concat", "slice", "contains", "indexOf", "keys", "values", + "upper", "lower", "pad", "fmt", + "random", "now" + ] + } + } +} diff --git a/middleware/packages/canvas-core/schema/ports-wires.schema.json b/middleware/packages/canvas-core/schema/ports-wires.schema.json new file mode 100644 index 00000000..ed1a8d1d --- /dev/null +++ b/middleware/packages/canvas-core/schema/ports-wires.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://omadia.ai/protocol/1.1/ports-wires.schema.json", + "title": "Ports, expose & wires — cross-element interaction", + "description": "omadia-canvas-protocol/1.1 (lumens-spec.md §7): typed ports for explicit wiring, a published read-only `expose` interface (ambient-by-declaration, not ambient-by-default), and `wire` routing by stable id. Least-privilege: a node reads ONLY what is wired or expose-published to it. All declared data, whitelist-validated, resolved by stable id ⇒ deterministic, replayable, shared-canvas-safe.", + + "$defs": { + "portType": { + "description": "the typed contract carried over a port / expose / wire", + "enum": ["selection", "viewport", "int", "number", "bool", "string", "enum", "list", "record", "grid", "dataRef", "any"] + }, + + "portSpec": { + "type": "object", "additionalProperties": false, "required": ["name", "dir", "type"], + "properties": { + "name": { "type": "string" }, + "dir": { "enum": ["in", "out"] }, + "type": { "$ref": "#/$defs/portType" } + } + }, + + "exposeSpec": { + "type": "object", "additionalProperties": false, "required": ["name", "type"], + "description": "published read-only view-state, bindable by shared id WITHOUT a wire. Un-exposed state stays private.", + "properties": { + "name": { "type": "string" }, + "type": { "$ref": "#/$defs/portType" } + } + }, + + "wire": { + "type": "object", "additionalProperties": false, "required": ["from", "to"], + "description": "routes a node's typed `out` port to another's `in` port by stable id; the host resolves and propagates at Tier 1 (Class A).", + "properties": { + "from": { "$ref": "#/$defs/wireEnd" }, + "to": { "$ref": "#/$defs/wireEnd" } + } + }, + + "wireEnd": { + "type": "object", "additionalProperties": false, "required": ["ref", "port"], + "properties": { + "ref": { "$ref": "https://omadia.ai/protocol/1.0/target-ref.schema.json" }, + "port": { "type": "string" } + } + }, + + "portsList": { "type": "array", "items": { "$ref": "#/$defs/portSpec" } }, + "exposeList": { "type": "array", "items": { "$ref": "#/$defs/exposeSpec" } }, + "wiresList": { "type": "array", "items": { "$ref": "#/$defs/wire" } } + } +} diff --git a/middleware/packages/canvas-core/schema/scene.schema.json b/middleware/packages/canvas-core/schema/scene.schema.json new file mode 100644 index 00000000..cb7e4cff --- /dev/null +++ b/middleware/packages/canvas-core/schema/scene.schema.json @@ -0,0 +1,138 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://omadia.ai/protocol/1.1/scene.schema.json", + "title": "Scene primitive (editor-class, 1.1)", + "description": "omadia-canvas-protocol/1.1 (lumens-spec.md §3): a declarative immediate-mode draw surface — the 25th primitive. A Lumen `view` emits, per render, a draw-list from a CLOSED shape vocabulary; there is no canvas 2d/webgl script. Colours are theme tokens + the active Lume palette ONLY (a scene is always on-theme). Coordinates are buffer-native (independent of zoom/pan). A SceneNode `id` is a stable element id ⇒ a TargetRef for beams/events/wires.", + + "$ref": "#/$defs/scene", + + "$defs": { + "scene": { + "type": "object", + "additionalProperties": false, + "required": ["type", "width", "height", "draw"], + "properties": { + "type": { "const": "scene" }, + "id": { "type": "string", "description": "stable element id" }, + "width": { "type": "integer", "minimum": 1, "description": "buffer-native coordinate space" }, + "height": { "type": "integer", "minimum": 1 }, + "camera": { + "type": "object", "additionalProperties": false, + "properties": { "x": { "type": "number" }, "y": { "type": "number" }, "zoom": { "type": "number", "exclusiveMinimum": 0 } } + }, + "draw": { "type": "array", "items": { "$ref": "#/$defs/sceneNode" } } + } + }, + + "colorToken": { + "description": "Lume theme tokens + active palette only. Free-form colours are clipped by the Tier-1 normaliser (§3).", + "enum": [ + "accent", "accent.glow", "accent.glow-soft", "accent.glow-core", + "surface", "surface-raised", "surface-sunken", + "text", "text-muted", "text-faint", + "neutral", "info", "success", "warning", "danger", "transparent" + ] + }, + + "typeRegister": { + "description": "the three Lume type registers (visual-spec.md §2.7)", + "enum": ["display", "prose", "mono"] + }, + + "transform": { + "type": "object", "additionalProperties": false, + "properties": { + "x": { "type": "number" }, "y": { "type": "number" }, + "scale": { "type": "number" }, "rotate": { "type": "number", "description": "degrees" } + } + }, + + "sceneNode": { + "type": "object", + "oneOf": [ + { "$ref": "#/$defs/n_rect" }, + { "$ref": "#/$defs/n_circle" }, + { "$ref": "#/$defs/n_line" }, + { "$ref": "#/$defs/n_path" }, + { "$ref": "#/$defs/n_sprite" }, + { "$ref": "#/$defs/n_text" }, + { "$ref": "#/$defs/n_group" } + ] + }, + + "n_rect": { + "type": "object", "additionalProperties": false, "required": ["kind", "x", "y", "w", "h"], + "properties": { + "kind": { "const": "rect" }, + "x": { "type": "number" }, "y": { "type": "number" }, "w": { "type": "number" }, "h": { "type": "number" }, + "r": { "type": "number", "minimum": 0, "description": "corner radius" }, + "fill": { "$ref": "#/$defs/colorToken" }, "stroke": { "$ref": "#/$defs/colorToken" }, "strokeW": { "type": "number", "minimum": 0 }, + "id": { "type": "string" }, "hitPadding": { "$ref": "#/$defs/hitPadding" } + } + }, + "n_circle": { + "type": "object", "additionalProperties": false, "required": ["kind", "cx", "cy", "r"], + "properties": { + "kind": { "const": "circle" }, + "cx": { "type": "number" }, "cy": { "type": "number" }, "r": { "type": "number", "minimum": 0 }, + "fill": { "$ref": "#/$defs/colorToken" }, "stroke": { "$ref": "#/$defs/colorToken" }, "strokeW": { "type": "number", "minimum": 0 }, + "id": { "type": "string" }, "hitPadding": { "$ref": "#/$defs/hitPadding" } + } + }, + "n_line": { + "type": "object", "additionalProperties": false, "required": ["kind", "x1", "y1", "x2", "y2", "stroke"], + "properties": { + "kind": { "const": "line" }, + "x1": { "type": "number" }, "y1": { "type": "number" }, "x2": { "type": "number" }, "y2": { "type": "number" }, + "stroke": { "$ref": "#/$defs/colorToken" }, "strokeW": { "type": "number", "minimum": 0 }, + "id": { "type": "string" }, "hitPadding": { "$ref": "#/$defs/hitPadding" } + } + }, + "n_path": { + "type": "object", "additionalProperties": false, "required": ["kind", "points"], + "properties": { + "kind": { "const": "path" }, + "points": { "type": "array", "items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 } }, + "closed": { "type": "boolean" }, + "fill": { "$ref": "#/$defs/colorToken" }, "stroke": { "$ref": "#/$defs/colorToken" }, "strokeW": { "type": "number", "minimum": 0 }, + "id": { "type": "string" }, "hitPadding": { "$ref": "#/$defs/hitPadding" } + } + }, + "n_sprite": { + "type": "object", "additionalProperties": false, "required": ["kind", "x", "y", "w", "h", "dataRef"], + "properties": { + "kind": { "const": "sprite" }, + "x": { "type": "number" }, "y": { "type": "number" }, "w": { "type": "number" }, "h": { "type": "number" }, + "dataRef": { "$ref": "https://omadia.ai/protocol/1.0/data-ref.schema.json" }, + "id": { "type": "string" }, "hitPadding": { "$ref": "#/$defs/hitPadding" } + } + }, + "n_text": { + "type": "object", "additionalProperties": false, "required": ["kind", "x", "y", "text"], + "properties": { + "kind": { "const": "text" }, + "x": { "type": "number" }, "y": { "type": "number" }, + "text": { "type": "string" }, + "size": { "type": "number", "exclusiveMinimum": 0 }, + "weight": { "type": "integer", "minimum": 100, "maximum": 900 }, + "register": { "$ref": "#/$defs/typeRegister" }, + "fill": { "$ref": "#/$defs/colorToken" }, + "id": { "type": "string" }, "hitPadding": { "$ref": "#/$defs/hitPadding" } + } + }, + "n_group": { + "type": "object", "additionalProperties": false, "required": ["kind", "children"], + "properties": { + "kind": { "const": "group" }, + "transform": { "$ref": "#/$defs/transform" }, + "children": { "type": "array", "items": { "$ref": "#/$defs/sceneNode" } }, + "id": { "type": "string" }, "hitPadding": { "$ref": "#/$defs/hitPadding" } + } + }, + + "hitPadding": { + "type": "number", "minimum": 0, + "description": "buffer-px expansion of the hit area; the runtime additionally enforces a 44pt minimum hit-target (§4) regardless of drawn glyph size" + } + } +} diff --git a/middleware/packages/canvas-core/src/capabilities/assetCache.ts b/middleware/packages/canvas-core/src/capabilities/assetCache.ts new file mode 100644 index 00000000..75a3d164 --- /dev/null +++ b/middleware/packages/canvas-core/src/capabilities/assetCache.ts @@ -0,0 +1,84 @@ +/** + * omadia-canvas-protocol/1.1 — content-addressed asset transport (lumens-spec.md §6.1). + * + * Binaries (images, audio, tiles, voice) travel as DataRefs that are + * content-addressed: `id = "-"`. Same bytes → same + * id; different bytes → different id. ALWAYS. This is cache-busting BY + * CONSTRUCTION — the id IS the content hash, so changed content is a different + * id and old content can never be addressed by a new reference. No time-based + * "maybe stale" guesswork; invalidation is explicit only. + */ +import { createHash } from 'node:crypto'; + +/** Compute the content-addressed id for a blob. `kind` ∈ {pixel,vector,audio, + * video,struct,…} (lowercase letters — matches the DataRef id pattern). */ +export function contentId(kind: string, content: string | Uint8Array): string { + if (!/^[a-z]+$/.test(kind)) throw new Error(`invalid asset kind '${kind}' (lowercase letters only)`); + const hash = createHash('sha256').update(content).digest('hex').slice(0, 16); + return `${kind}-${hash}`; +} + +export interface CachedAsset { + id: string; + kind: string; + content: string | Uint8Array; + /** ISO 8601; undefined = no expiry. */ + expiresAt?: string; +} + +/** A local content-addressed store keyed by id: instant hits across + * turns/Lumens/canvases, automatic dedup, explicit-only invalidation. */ +export class ContentAddressedStore { + private readonly entries = new Map(); + private readonly refs = new Map(); + + /** Store a blob, returning its content-addressed id. Idempotent: identical + * bytes reuse the same entry (dedup), never a second copy. */ + put(kind: string, content: string | Uint8Array, expiresAt?: string): string { + const id = contentId(kind, content); + if (!this.entries.has(id)) this.entries.set(id, { id, kind, content, expiresAt }); + return id; + } + + get(id: string): CachedAsset | undefined { + return this.entries.get(id); + } + has(id: string): boolean { + return this.entries.has(id); + } + + /** Track a live reference (a Lumen/scene using this asset). */ + retain(id: string): void { + this.refs.set(id, (this.refs.get(id) ?? 0) + 1); + } + release(id: string): void { + const n = (this.refs.get(id) ?? 0) - 1; + if (n <= 0) this.refs.delete(id); + else this.refs.set(id, n); + } + + /** Explicit invalidation (expiresAt passed or surface_data_ref_invalidated). */ + invalidate(id: string): void { + this.entries.delete(id); + this.refs.delete(id); + } + + /** GC entries that are BOTH expired and unreferenced (never time-guesswork on + * live or unexpired assets). `now` is an ISO timestamp (injected, testable). */ + gc(now: string): string[] { + const removed: string[] = []; + for (const [id, asset] of this.entries) { + const expired = asset.expiresAt !== undefined && asset.expiresAt <= now; + const referenced = (this.refs.get(id) ?? 0) > 0; + if (expired && !referenced) { + this.entries.delete(id); + removed.push(id); + } + } + return removed; + } + + get size(): number { + return this.entries.size; + } +} diff --git a/middleware/packages/canvas-core/src/capabilities/broker.ts b/middleware/packages/canvas-core/src/capabilities/broker.ts new file mode 100644 index 00000000..af6ccb9c --- /dev/null +++ b/middleware/packages/canvas-core/src/capabilities/broker.ts @@ -0,0 +1,101 @@ +/** + * omadia-canvas-protocol/1.1 — capability broker egress bounds (lumens-spec.md §6). + * + * Because a capability call can be emitted from a `tick`/`timer`, Tier-2 bounds + * EGRESS the way Tier-1 bounds compute (§0.2): per-capability rate + quota, a + * max-in-flight ceiling, idempotent de-duplication of identical in-flight calls, + * and backpressure when a broker saturates — so a ticking Lumen cannot move the + * DoS/cost problem onto Tier-2/3. Pure and clock-injected (now passed in) so it + * is deterministic and unit-testable. + */ +import type { CapabilityName } from './effects.js'; + +export interface CapLimits { + /** max calls admitted per rolling window. */ + ratePerWindow: number; + /** rolling window length in ms. */ + windowMs: number; + /** total calls admitted over the Lumen's lifetime (cost ceiling). */ + quota: number; + /** max concurrent in-flight calls. */ + maxInFlight: number; +} + +export type AdmitResult = + | { ok: true; deduped: false } + | { ok: true; deduped: true } // identical call already in flight — coalesced + | { ok: false; reason: 'rate' | 'quota' | 'backpressure' }; + +interface CapState { + recent: number[]; // admit timestamps within the window + used: number; // lifetime count + inFlight: Map; // idempotencyKey → refcount +} + +/** Spike-tunable defaults (§14) — conservative; the real contract is a spike + * deliverable. Per-capability so an expensive cap (generateAsset) is tighter. */ +export const DEFAULT_LIMITS: Record = { + persist: { ratePerWindow: 20, windowMs: 1000, quota: 10_000, maxInFlight: 4 }, + loadData: { ratePerWindow: 20, windowMs: 1000, quota: 10_000, maxInFlight: 4 }, + writeData: { ratePerWindow: 5, windowMs: 1000, quota: 1_000, maxInFlight: 2 }, + tiles: { ratePerWindow: 30, windowMs: 1000, quota: 50_000, maxInFlight: 8 }, + fetch: { ratePerWindow: 5, windowMs: 1000, quota: 1_000, maxInFlight: 2 }, + generateAsset: { ratePerWindow: 2, windowMs: 1000, quota: 200, maxInFlight: 1 }, + clipboard: { ratePerWindow: 2, windowMs: 1000, quota: 100, maxInFlight: 1 }, + share: { ratePerWindow: 1, windowMs: 2000, quota: 50, maxInFlight: 1 }, + savePreset: { ratePerWindow: 1, windowMs: 2000, quota: 50, maxInFlight: 1 }, +}; + +/** Per-Lumen broker limiter. One instance per Lumen instance; `admit`/`settle` + * bracket each brokered call. Idempotent de-dup coalesces identical in-flight + * calls (same cap + key) so a tick storm of identical requests is one call. */ +export class BrokerLimiter { + private readonly state = new Map(); + + constructor(private readonly limits: Record = DEFAULT_LIMITS) {} + + private get(cap: CapabilityName): CapState { + let s = this.state.get(cap); + if (!s) { + s = { recent: [], used: 0, inFlight: new Map() }; + this.state.set(cap, s); + } + return s; + } + + /** Try to admit a call. `key` identifies an idempotent call for de-dup. */ + admit(cap: CapabilityName, key: string, now: number): AdmitResult { + const limit = this.limits[cap]; + const s = this.get(cap); + + // identical call already in flight ⇒ coalesce (does not consume rate/quota). + if (s.inFlight.has(key)) { + s.inFlight.set(key, s.inFlight.get(key)! + 1); + return { ok: true, deduped: true }; + } + // backpressure: too many distinct calls in flight. + if (s.inFlight.size >= limit.maxInFlight) return { ok: false, reason: 'backpressure' }; + // lifetime quota. + if (s.used >= limit.quota) return { ok: false, reason: 'quota' }; + // rolling-window rate. + s.recent = s.recent.filter((t) => now - t < limit.windowMs); + if (s.recent.length >= limit.ratePerWindow) return { ok: false, reason: 'rate' }; + + s.recent.push(now); + s.used += 1; + s.inFlight.set(key, 1); + return { ok: true, deduped: false }; + } + + /** Mark a call (and any coalesced duplicates) complete, freeing in-flight slots. */ + settle(cap: CapabilityName, key: string): void { + const s = this.state.get(cap); + if (!s) return; + s.inFlight.delete(key); + } + + /** Remaining lifetime quota for a capability. */ + remaining(cap: CapabilityName): number { + return Math.max(0, this.limits[cap].quota - (this.state.get(cap)?.used ?? 0)); + } +} diff --git a/middleware/packages/canvas-core/src/capabilities/consent.ts b/middleware/packages/canvas-core/src/capabilities/consent.ts new file mode 100644 index 00000000..887e7fa0 --- /dev/null +++ b/middleware/packages/canvas-core/src/capabilities/consent.ts @@ -0,0 +1,61 @@ +/** + * omadia-canvas-protocol/1.1 — import consent for shared/preset Lumens (lumens-spec.md §6, §9). + * + * An imported or shared Lumen surfaces its capability manifest for consent + * BEFORE first run. This pure module computes, from a manifest, what to show and + * which capabilities require explicit user consent (anything that can reach + * outside the host — worst-case external-effect). Determinism + per-user grants + * mean a shared game saves *your* score, not the author's. + */ +import { classifyEffect, isKnownCapability, type CapabilityName, type EffectClass } from './effects.js'; + +export interface CapabilityRequest { + cap: string; + effect?: EffectClass; + scope?: Record; +} + +export interface ConsentItem { + cap: CapabilityName; + /** worst-case effect (assume state-derived egress, not pre-approved). */ + worstCase: EffectClass; + requiresConsent: boolean; +} + +export interface ConsentReport { + /** every declared capability, surfaced to the user. */ + shown: ConsentItem[]; + /** the subset the user must explicitly approve before first run. */ + requiresConsent: CapabilityName[]; + /** an unknown capability name in the manifest ⇒ reject the import wholesale. */ + unknown: string[]; +} + +/** Compute the consent report for a Lumen's capability manifest. */ +export function consentForManifest(manifest: CapabilityRequest[]): ConsentReport { + const shown: ConsentItem[] = []; + const requiresConsent: CapabilityName[] = []; + const unknown: string[] = []; + + for (const req of manifest) { + if (!isKnownCapability(req.cap)) { + unknown.push(req.cap); + continue; + } + // worst case at consent time: assume the call WILL carry state-derived data + // and was NOT pre-approved — the most cautious classification (§6). + const worstCase = classifyEffect(req.cap, { stateDerived: true, preApproved: false }).effect; + const needs = worstCase === 'external-effect'; + shown.push({ cap: req.cap, worstCase, requiresConsent: needs }); + if (needs) requiresConsent.push(req.cap); + } + + return { shown, requiresConsent, unknown }; +} + +/** An imported Lumen is safe to run only if no capability is unknown (the + * whitelist discipline) — consent on the external-effect subset is then + * collected interactively. */ +export function manifestIsImportable(manifest: CapabilityRequest[]): boolean { + return consentForManifest(manifest).unknown.length === 0; +} diff --git a/middleware/packages/canvas-core/src/capabilities/effects.ts b/middleware/packages/canvas-core/src/capabilities/effects.ts new file mode 100644 index 00000000..697e6741 --- /dev/null +++ b/middleware/packages/canvas-core/src/capabilities/effects.ts @@ -0,0 +1,75 @@ +/** + * omadia-canvas-protocol/1.1 — capability effect classification (lumens-spec.md §6). + * + * Default-deny, brokered capabilities. This pure module decides a capability + * call's effect class (local / internal / external-effect) and whether it needs + * a per-call user confirmation — the policy the Tier-2 broker enforces. The key + * rule (§6, Codex rev 2): egress carrying data DERIVED from Lumen state or a + * DataRef is `external-effect` (confirmed) UNLESS the endpoint+shape were + * pre-approved at grant time — a bare `internal` fetch may not smuggle + * state-derived data past the confirmation gate. Tier-2 may UPGRADE the class, + * never downgrade it. + */ +export type CapabilityName = + | 'persist' | 'loadData' | 'writeData' | 'tiles' | 'fetch' | 'generateAsset' | 'clipboard' | 'share' | 'savePreset'; + +export type EffectClass = 'local' | 'internal' | 'external-effect'; + +/** Base effect class per capability (CONCEPT.md §Security Surface). */ +const BASE_EFFECT: Record = { + persist: 'internal', + loadData: 'internal', + writeData: 'internal', + tiles: 'internal', + fetch: 'internal', + generateAsset: 'internal', + clipboard: 'external-effect', + share: 'external-effect', + savePreset: 'external-effect', +}; + +/** Capabilities whose egress can carry data out of the host. */ +const EGRESS_CAPS: ReadonlySet = new Set(['fetch', 'writeData', 'generateAsset']); + +export interface ClassifyInput { + /** the call carries data derived from Lumen state or a DataRef. */ + stateDerived?: boolean; + /** the endpoint AND request shape were pre-approved at grant time. */ + preApproved?: boolean; +} + +export interface EffectDecision { + effect: EffectClass; + /** a per-call confirmation modal is required before the real call. */ + needsConfirmation: boolean; + reason: string; +} + +const ORDER: Record = { local: 0, internal: 1, 'external-effect': 2 }; + +/** Classify a capability call. The result is the class the broker enforces; + * external-effect always requires confirmation. */ +export function classifyEffect(cap: CapabilityName, input: ClassifyInput = {}): EffectDecision { + let effect = BASE_EFFECT[cap]; + let reason = `base class for ${cap}`; + + // state/DataRef-derived egress escalates to external-effect unless pre-approved. + if (EGRESS_CAPS.has(cap) && input.stateDerived && !input.preApproved) { + if (ORDER['external-effect'] > ORDER[effect]) { + effect = 'external-effect'; + reason = `${cap} carries state/DataRef-derived data and was not pre-approved at grant`; + } + } + + return { effect, needsConfirmation: effect === 'external-effect', reason }; +} + +/** Tier-2 may UPGRADE an agent's declared class, never downgrade it (§0.5). */ +export function reconcileDeclared(declared: EffectClass | undefined, derived: EffectClass): EffectClass { + if (!declared) return derived; + return ORDER[declared] >= ORDER[derived] ? declared : derived; +} + +export function isKnownCapability(cap: string): cap is CapabilityName { + return cap in BASE_EFFECT; +} diff --git a/middleware/packages/canvas-core/src/capabilities/index.ts b/middleware/packages/canvas-core/src/capabilities/index.ts new file mode 100644 index 00000000..f03a08f3 --- /dev/null +++ b/middleware/packages/canvas-core/src/capabilities/index.ts @@ -0,0 +1,8 @@ +// omadia-canvas-protocol/1.1 — Lumen capability broker policy (Tier-2 core). +// Pure, deterministic, unit-testable policy the orchestrator/channel enforce. +export * from './effects.js'; +export * from './broker.js'; +export * from './assetCache.js'; +export * from './consent.js'; +export * from './presets.js'; +export * from './sharing.js'; diff --git a/middleware/packages/canvas-core/src/capabilities/presets.ts b/middleware/packages/canvas-core/src/capabilities/presets.ts new file mode 100644 index 00000000..a6a990f9 --- /dev/null +++ b/middleware/packages/canvas-core/src/capabilities/presets.ts @@ -0,0 +1,109 @@ +/** + * omadia-canvas-protocol/1.1 — Lumen presets & lifecycle (lumens-spec.md §8). + * + * The agent authors rarely and reuses constantly. A vetted Lumen is saved once: + * named, versioned, CONTENT-ADDRESSED (`preset-`), + * parameterised. Instantiation is deterministic, near-zero-LLM. Before any build, + * Tier-2 runs resolve-then-generate: exact hit → instantiate; near hit → fork + + * patch; miss → cold-author. Fork is copy-on-write → new content-addressed id, + * parent recorded for provenance. All pure & deterministic (unit-testable). + */ +import { createHash } from 'node:crypto'; + +/** First-match-wins scope precedence (§8). */ +export type PresetScope = 'first-party' | 'tenant' | 'user' | 'canvas'; +const SCOPE_ORDER: PresetScope[] = ['first-party', 'tenant', 'user', 'canvas']; + +/** Stable serialisation (sorted keys) so identical specs hash identically + * regardless of key order. */ +export function canonicalize(value: unknown): string { + const seen = new WeakSet(); + const walk = (v: unknown): unknown => { + if (v === null || typeof v !== 'object') return v; + if (seen.has(v as object)) throw new Error('cannot canonicalize a cyclic value'); + seen.add(v as object); + if (Array.isArray(v)) return v.map(walk); + const out: Record = {}; + for (const k of Object.keys(v as Record).sort()) out[k] = walk((v as Record)[k]); + return out; + }; + return JSON.stringify(walk(value)); +} + +/** The content-addressed preset id for a Lumen spec. The `preset` provenance + * field is excluded from the hash so lineage never changes the content id. */ +export function presetId(spec: Record): string { + const { preset: _omit, ...content } = spec; + void _omit; + const hash = createHash('sha256').update(canonicalize(content)).digest('hex').slice(0, 16); + return `preset-${hash}`; +} + +/** A coarse structural signature for near-hit detection: same state keys + + * transition names + view container/scene kind ⇒ a fork+patch candidate. */ +export function shapeSignature(spec: Record): string { + const state = spec.state && typeof spec.state === 'object' ? Object.keys(spec.state as object).sort() : []; + const transitions = spec.transitions && typeof spec.transitions === 'object' ? Object.keys(spec.transitions as object).sort() : []; + const view = spec.view as { lit?: { type?: string }; record?: { type?: { lit?: string } } } | undefined; + const viewKind = view?.record?.type?.lit ?? view?.lit?.type ?? 'expr'; + return canonicalize({ state, transitions, viewKind }); +} + +export type ResolveResult = + | { kind: 'exact'; id: string; scope: PresetScope } + | { kind: 'near'; id: string; scope: PresetScope; signature: string } + | { kind: 'miss' }; + +interface Entry { + id: string; + scope: PresetScope; + signature: string; + spec: Record; +} + +/** A scoped preset registry implementing resolve-then-generate (§8). */ +export class PresetRegistry { + private readonly byId = new Map(); + + /** Save a vetted Lumen as a preset; returns its content-addressed id. */ + register(spec: Record, scope: PresetScope = 'canvas'): string { + const id = presetId(spec); + if (!this.byId.has(id)) this.byId.set(id, { id, scope, signature: shapeSignature(spec), spec }); + return id; + } + + get(id: string): Record | undefined { + return this.byId.get(id)?.spec; + } + + /** Resolve-then-generate: exact content hit → instantiate; else the + * highest-scope structural near-hit → fork+patch; else miss → cold-author. */ + resolve(query: Record): ResolveResult { + const exactId = presetId(query); + const exact = this.byId.get(exactId); + if (exact) return { kind: 'exact', id: exact.id, scope: exact.scope }; + + const sig = shapeSignature(query); + const candidates = [...this.byId.values()].filter((e) => e.signature === sig); + if (candidates.length > 0) { + candidates.sort((a, b) => SCOPE_ORDER.indexOf(a.scope) - SCOPE_ORDER.indexOf(b.scope)); + const best = candidates[0]!; + return { kind: 'near', id: best.id, scope: best.scope, signature: sig }; + } + return { kind: 'miss' }; + } +} + +/** Copy-on-write fork: apply a shallow patch over the parent spec, record the + * parent id for provenance, and compute the child's new content-addressed id. */ +export function forkPreset( + parentSpec: Record, + patch: Record, +): { spec: Record; id: string; parent: string } { + const parent = presetId(parentSpec); + const merged: Record = { ...parentSpec, ...patch }; + delete merged.preset; // recomputed below; never inherit the parent's provenance + const id = presetId(merged); + merged.preset = { id, parent }; + return { spec: merged, id, parent }; +} diff --git a/middleware/packages/canvas-core/src/capabilities/sharing.ts b/middleware/packages/canvas-core/src/capabilities/sharing.ts new file mode 100644 index 00000000..8f043b9e --- /dev/null +++ b/middleware/packages/canvas-core/src/capabilities/sharing.ts @@ -0,0 +1,115 @@ +/** + * omadia-canvas-protocol/1.1 — Lumen sharing (lumens-spec.md §9). + * + * A Lumen serialises cleanly (validated data + capability manifest). The hard + * rule: assets travel by content `id`, NOT by token. A shared/preset Lumen + * carries content-addressed DataRef ids only — NEVER the author's signedTokens + * (HMAC-scoped to the author's tenant‖user‖canvasSession, §6.1), which would + * either fail for the recipient or break isolation. On import the recipient's + * Tier-2 re-authorises and re-mints each token scoped to the recipient; an asset + * the recipient may not access renders INERT, never via a borrowed token. + * + * Pure: the real HMAC mint + authorisation are injected (the host owns the + * secret), so this policy is deterministic and unit-testable. + */ +import { consentForManifest, manifestIsImportable, type CapabilityRequest, type ConsentReport } from './consent.js'; + +interface DataRefLike { + id: string; + signedToken?: string; + expiresAt?: string; + [k: string]: unknown; +} + +// A DataRef is content-addressed `-<16hex>` (data-ref.schema.json). We +// detect by that id shape — NOT by the presence of `signedToken`, because a +// shared ref has had its token stripped. `preset-` ids are provenance, not +// assets, so they are excluded. +const DATA_REF_ID = /^(?!preset-)[a-z]+-[0-9a-f]{16}$/; +const isDataRefLike = (v: unknown): v is DataRefLike => + typeof v === 'object' && + v !== null && + !Array.isArray(v) && + typeof (v as { id?: unknown }).id === 'string' && + DATA_REF_ID.test((v as { id: string }).id); + +/** Deep-map a tree, applying `fn` to every DataRef-like object (one with an + * `id` and a `signedToken` slot). Returns a fresh structure (no mutation). */ +function mapDataRefs(node: T, fn: (ref: DataRefLike) => DataRefLike): T { + if (Array.isArray(node)) return node.map((n) => mapDataRefs(n, fn)) as unknown as T; + if (node && typeof node === 'object') { + if (isDataRefLike(node)) return fn({ ...(node as DataRefLike) }) as unknown as T; + const out: Record = {}; + for (const [k, v] of Object.entries(node as Record)) out[k] = mapDataRefs(v, fn); + return out as unknown as T; + } + return node; +} + +/** Prepare a Lumen for sharing: strip every author signedToken/expiresAt, + * keeping only the content-addressed id. Returns the shareable spec + the set + * of asset ids it references. */ +export function stripTokensForShare(lumen: T): { shared: T; assetIds: string[] } { + const ids = new Set(); + const shared = mapDataRefs(lumen, (ref) => { + ids.add(ref.id); + const { signedToken: _t, expiresAt: _e, ...rest } = ref; + void _t; + void _e; + return rest as DataRefLike; + }); + return { shared, assetIds: [...ids] }; +} + +export type AuthorizeAsset = (id: string, recipient: string) => boolean; +export type MintToken = (id: string, recipient: string) => { signedToken: string; expiresAt: string }; + +export interface ImportResult { + /** the Lumen with recipient-scoped tokens; un-authorised assets marked inert. */ + lumen: T; + /** the capability consent report the recipient must satisfy before first run. */ + consent: ConsentReport; + /** importable iff no unknown capability (whitelist) — else do not run. */ + importable: boolean; + /** asset ids re-minted for the recipient. */ + reminted: string[]; + /** asset ids the recipient may not access → rendered inert. */ + inert: string[]; +} + +/** Import a shared Lumen for a recipient: re-validate capabilities, then + * re-mint a recipient-scoped token for each authorised asset and mark the rest + * inert (an inert ref carries no token and an `inert:true` flag the renderer + * honours). */ +export function importShared( + shared: T, + recipient: string, + opts: { manifest?: CapabilityRequest[]; authorize: AuthorizeAsset; mint: MintToken }, +): ImportResult { + const manifest = opts.manifest ?? []; + const consent = consentForManifest(manifest); + const reminted: string[] = []; + const inert: string[] = []; + + const lumen = mapDataRefs(shared, (ref) => { + // never trust an inbound token — always re-derive from scratch. + const { signedToken: _t, expiresAt: _e, inert: _i, ...base } = ref as DataRefLike & { inert?: boolean }; + void _t; + void _e; + void _i; + if (opts.authorize(ref.id, recipient)) { + const minted = opts.mint(ref.id, recipient); + reminted.push(ref.id); + return { ...base, signedToken: minted.signedToken, expiresAt: minted.expiresAt }; + } + inert.push(ref.id); + return { ...base, inert: true }; + }); + + return { lumen, consent, importable: manifestIsImportable(manifest), reminted, inert }; +} + +/** §9: canvasOwnership extends to a group of members for shared canvases. */ +export function canvasOwnershipGroup(members: string[]): { kind: 'group'; members: string[] } { + return { kind: 'group', members: [...new Set(members)] }; +} diff --git a/middleware/packages/canvas-core/src/index.ts b/middleware/packages/canvas-core/src/index.ts index 4b99ab82..c31844fd 100644 --- a/middleware/packages/canvas-core/src/index.ts +++ b/middleware/packages/canvas-core/src/index.ts @@ -4,4 +4,16 @@ export * from './canvasStore.js'; export * from './handshake.js'; export * from './canvasSocket.js'; export * from './connection.js'; -export { validateTree, validateSurfaceEvent, type ValidationResult } from './validator.js'; +export { + validateTree, + validateSurfaceEvent, + validateLumen, + validateLumenFull, + validateScene, + validateLxNode, + type ValidationResult, +} from './validator.js'; +// omadia-canvas-protocol/1.1 — Lumens (Live Interactivity) Tier-1 LX runtime. +export * from './lx/index.js'; +// omadia-canvas-protocol/1.1 — Lumen capability broker policy (Tier-2 core). +export * from './capabilities/index.js'; diff --git a/middleware/packages/canvas-core/src/lx/index.ts b/middleware/packages/canvas-core/src/lx/index.ts new file mode 100644 index 00000000..0d99b683 --- /dev/null +++ b/middleware/packages/canvas-core/src/lx/index.ts @@ -0,0 +1,3 @@ +export * from './types.js'; +export { evaluate, runTransition } from './interpreter.js'; +export { validateLumenSemantics, type SemanticResult } from './validate.js'; diff --git a/middleware/packages/canvas-core/src/lx/interpreter.ts b/middleware/packages/canvas-core/src/lx/interpreter.ts new file mode 100644 index 00000000..a0597a34 --- /dev/null +++ b/middleware/packages/canvas-core/src/lx/interpreter.ts @@ -0,0 +1,404 @@ +/** + * omadia-canvas-protocol/1.1 — the Tier-1 LX interpreter (lumens-spec.md §2). + * + * Pure, total, deterministic AST evaluator. No `eval`/`Function` (CSP stays + * `default-src 'self'`). Every node costs gas; iteration is bounded; `random` + * and `now` are host-seeded. Given identical (state, event, seed, now) the + * result is byte-identical on every machine — the basis for replay, undo, + * sharing and v2 multi-user (§0.3). Any structural surprise throws LxError and + * the host halts that Lumen with surface_error (never the canvas, §0.2). + */ +import { + DEFAULT_GAS, + LxError, + MAX_DEPTH, + MAX_ITERATIONS, + MAX_RANGE, + MAX_VALUE_SIZE, + type EvalOptions, + type LxNode, + type LxValue, + type StateValue, +} from './types.js'; + +interface Ctx { + state: StateValue; + event: Record; + env: Record; // lexical bindings (let / it / idx / acc) + now: number; + gas: { n: number }; + depth: number; + rng: () => number; +} + +/** Deterministic PRNG (mulberry32) — pure arithmetic, fully replayable. */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const isRecord = (v: unknown): v is { [k: string]: LxValue } => + typeof v === 'object' && v !== null && !Array.isArray(v); + +/** Prototype-pollution guard. LX is data, but a `set`/`record`/`state` path + * segment of `__proto__`/`prototype`/`constructor` would let declarative data + * reach the JS object graph — reject it hard (defence in depth, independent of + * the semantic validator). */ +const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); +function safeKey(key: string): string { + if (FORBIDDEN_KEYS.has(key)) throw new LxError('bad-path', `forbidden key '${key}'`); + return key; +} + +function burn(ctx: Ctx, amount = 1): void { + ctx.gas.n -= amount; + if (ctx.gas.n < 0) throw new LxError('gas', 'gas budget exhausted'); +} + +/** Charge gas proportional to a produced value's size AND cap it — so a + * size-amplifying op (concat-doubling, pad, map-over-range) cannot explode + * memory while staying under the per-node gas budget (F1). */ +function chargeSize(ctx: Ctx, size: number): void { + if (size > MAX_VALUE_SIZE) throw new LxError('bounds', `produced value size ${size} exceeds the ${MAX_VALUE_SIZE} cap`); + burn(ctx, size); +} + +/** Guard a numeric result against NaN / ±Infinity / -0 divergence: non-finite + * numbers serialise lossily (JSON → null) and would break replay determinism + * (F8). A non-finite arithmetic result halts the Lumen instead. */ +function fin(n: number): number { + if (!Number.isFinite(n)) throw new LxError('bounds', 'non-finite numeric result'); + return n === 0 ? 0 : n; // normalise -0 → 0 +} + +/** Per-stdlib argument arity (F7). undefined max ⇒ variadic ≥ min. */ +const ARITY: Record = { + map: [2, 2], filter: [2, 2], fold: [3, 3], range: [1, 1], len: [1, 1], + min: [1, Infinity], max: [1, Infinity], clamp: [3, 3], + abs: [1, 1], floor: [1, 1], ceil: [1, 1], round: [1, 1], sqrt: [1, 1], sign: [1, 1], pow: [2, 2], + concat: [1, Infinity], slice: [2, 3], contains: [2, 2], indexOf: [2, 2], keys: [1, 1], values: [1, 1], + upper: [1, 1], lower: [1, 1], pad: [2, 3], fmt: [1, 1], random: [0, 0], now: [0, 0], +}; + +function asNumber(v: LxValue): number { + if (typeof v !== 'number') throw new LxError('type', `expected number, got ${typeof v}`); + return v; +} +function asBool(v: LxValue): boolean { + if (typeof v !== 'boolean') throw new LxError('type', `expected bool, got ${typeof v}`); + return v; +} +function asString(v: LxValue): string { + if (typeof v !== 'string') throw new LxError('type', `expected string, got ${typeof v}`); + return v; +} +function asList(v: LxValue): LxValue[] { + if (!Array.isArray(v)) throw new LxError('type', `expected list, got ${typeof v}`); + return v; +} + +/** Structural deep-equality for `==` / `!=` / `contains` (total, gas-free at + * call sites that already burned for their operands). */ +function deepEqual(a: LxValue, b: LxValue, depth = 0): boolean { + if (depth > MAX_DEPTH) throw new LxError('bounds', 'value nesting too deep'); + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]!, depth + 1)); + } + if (isRecord(a) && isRecord(b)) { + const ka = Object.keys(a); + const kb = Object.keys(b); + return ka.length === kb.length && ka.every((k) => k in b && deepEqual(a[k]!, b[k]!, depth + 1)); + } + return false; +} + +/** Read a dotted path into a record/list value. */ +function readPath(root: LxValue, path: string): LxValue { + let cur: LxValue = root; + for (const seg of path.split('.')) { + safeKey(seg); + if (isRecord(cur) && Object.prototype.hasOwnProperty.call(cur, seg)) cur = cur[seg]!; + else if (Array.isArray(cur) && /^\d+$/.test(seg) && Number(seg) < cur.length) cur = cur[Number(seg)]!; + else throw new LxError('bad-path', `path '${path}' does not resolve`); + } + return cur; +} + +/** Immutable set of a dotted path; intermediate records are cloned, not mutated. */ +function setPath(root: StateValue, path: string, value: LxValue): StateValue { + const segs = path.split('.'); + segs.forEach(safeKey); + // a Lumen's state is a CLOSED record (§1.1): `set` may update declared paths, + // never invent a new top-level key (F5). + if (!Object.prototype.hasOwnProperty.call(root, segs[0]!)) { + throw new LxError('bad-path', `set target '${path}' is not a declared state key`); + } + const clone: StateValue = { ...root }; + let cur: { [k: string]: LxValue } = clone; + for (let i = 0; i < segs.length - 1; i++) { + const seg = segs[i]!; + const next = cur[seg]; + if (!isRecord(next)) throw new LxError('bad-path', `cannot set into non-record at '${seg}'`); + const copied = { ...next }; + cur[seg] = copied; + cur = copied; + } + cur[segs[segs.length - 1]!] = value; + return clone; +} + +function evalNode(node: LxNode, ctx: Ctx): LxValue { + burn(ctx); + // every node must be an object (F3): a missing child (e.g. an `if` with no + // `else` that slipped past the schema) becomes a typed LxError, never a raw + // TypeError that escapes the host's surface_error channel. + if (node === null || typeof node !== 'object' || Array.isArray(node)) { + throw new LxError('unknown-node', `expected an LX node object, got ${node === null ? 'null' : typeof node}`); + } + // bound AST recursion depth (F4) — deeper would overflow the JS stack with an + // uncatchable RangeError. Cloning with depth+1 (rather than mutating) means + // siblings each see the true nesting depth, not an accumulated count. + if (ctx.depth >= MAX_DEPTH) throw new LxError('bounds', 'expression nesting too deep'); + ctx = { ...ctx, depth: ctx.depth + 1 }; + const n = node as Record; + + if ('lit' in n) return n.lit as LxValue; + if ('var' in n) { + const name = n.var as string; + if (!(name in ctx.env)) throw new LxError('bad-path', `unbound var '${name}'`); + return ctx.env[name]!; + } + if ('state' in n) { + const base = readPath(ctx.state, n.state as string); + if (n.at) { + const [xn, yn] = n.at as [LxNode, LxNode]; + const x = asNumber(evalNode(xn, ctx)); + const y = asNumber(evalNode(yn, ctx)); + const row = asList(base)[y]; + if (row === undefined) throw new LxError('bounds', `grid row ${y} out of range`); + const cell = asList(row)[x]; + if (cell === undefined) throw new LxError('bounds', `grid col ${x} out of range`); + return cell; + } + return base; + } + if ('event' in n) { + const field = n.event as string; + return field in ctx.event ? ctx.event[field]! : 0; + } + if ('let' in n) { + const binding = n.let as Record; + const [name] = Object.keys(binding); + const value = evalNode(binding[name!]!, ctx); + const child: Ctx = { ...ctx, env: { ...ctx.env, [name!]: value } }; + return evalNode(n.in as LxNode, child); + } + + // arithmetic (all results guarded finite — F8) + if ('+' in n) return fin((n['+'] as LxNode[]).reduce((a, e) => a + asNumber(evalNode(e, ctx)), 0)); + if ('*' in n) return fin((n['*'] as LxNode[]).reduce((a, e) => a * asNumber(evalNode(e, ctx)), 1)); + if ('-' in n) { + const xs = (n['-'] as LxNode[]).map((e) => asNumber(evalNode(e, ctx))); + return fin(xs.slice(1).reduce((a, b) => a - b, xs[0]!)); + } + if ('/' in n) { + const [a, b] = (n['/'] as [LxNode, LxNode]).map((e) => asNumber(evalNode(e, ctx))) as [number, number]; + if (b === 0) throw new LxError('div-zero', 'division by zero'); + return fin(a / b); + } + if ('mod' in n) { + const [a, b] = (n.mod as [LxNode, LxNode]).map((e) => asNumber(evalNode(e, ctx))) as [number, number]; + if (b === 0) throw new LxError('div-zero', 'mod by zero'); + return fin(a % b); + } + + // comparison + if ('>' in n) { const [a, b] = binNum(n['>'] as LxNode[], ctx); return a > b; } + if ('>=' in n) { const [a, b] = binNum(n['>='] as LxNode[], ctx); return a >= b; } + if ('<' in n) { const [a, b] = binNum(n['<'] as LxNode[], ctx); return a < b; } + if ('<=' in n) { const [a, b] = binNum(n['<='] as LxNode[], ctx); return a <= b; } + if ('==' in n) { const [a, b] = (n['=='] as LxNode[]).map((e) => evalNode(e, ctx)); return deepEqual(a!, b!); } + if ('!=' in n) { const [a, b] = (n['!='] as LxNode[]).map((e) => evalNode(e, ctx)); return !deepEqual(a!, b!); } + + // logic (short-circuit) + if ('and' in n) { for (const e of n.and as LxNode[]) if (!asBool(evalNode(e, ctx))) return false; return true; } + if ('or' in n) { for (const e of n.or as LxNode[]) if (asBool(evalNode(e, ctx))) return true; return false; } + if ('not' in n) return !asBool(evalNode(n.not as LxNode, ctx)); + + // conditionals (total) + if ('if' in n) return asBool(evalNode(n.if as LxNode, ctx)) ? evalNode(n.then as LxNode, ctx) : evalNode(n.else as LxNode, ctx); + if ('match' in n) { + const subject = evalNode(n.match as LxNode, ctx); + for (const c of n.cases as { when: LxNode; then: LxNode }[]) { + if (deepEqual(subject, evalNode(c.when, ctx))) return evalNode(c.then, ctx); + } + return evalNode(n.else as LxNode, ctx); + } + + // constructors (size-charged — F1) + if ('record' in n) { + const entries = Object.entries(n.record as Record); + chargeSize(ctx, entries.length); + const out: { [k: string]: LxValue } = {}; + for (const [k, e] of entries) out[safeKey(k)] = evalNode(e, ctx); + return out; + } + if ('list' in n) { + const items = n.list as LxNode[]; + chargeSize(ctx, items.length); + return items.map((e) => evalNode(e, ctx)); + } + + // projection: read a field of a record (string key) or element of a list (int index) + if ('get' in n) { + const container = evalNode(n.get as LxNode, ctx); + const key = evalNode(n.key as LxNode, ctx); + if (Array.isArray(container)) { + if (typeof key !== 'number' || !Number.isInteger(key)) throw new LxError('type', 'list index must be an int'); + const el = container[key]; + if (el === undefined) throw new LxError('bounds', `list index ${key} out of range`); + return el; + } + if (isRecord(container)) { + if (typeof key !== 'string') throw new LxError('type', 'record key must be a string'); + if (!Object.prototype.hasOwnProperty.call(container, safeKey(key))) throw new LxError('bad-path', `record has no key '${key}'`); + return container[key]!; + } + throw new LxError('type', 'get expects a record or list'); + } + + // functional state update + if ('set' in n) { + const updates = n.set as Record; + // evaluate all exprs against the ORIGINAL state, then apply (functional). + const evaluated = Object.entries(updates).map(([path, e]) => [path, evalNode(e, ctx)] as const); + let next: StateValue = ctx.state; + for (const [path, value] of evaluated) next = setPath(next, path, value); + return next; + } + + if ('call' in n) return evalCall(n.call as string, n.args as LxNode[], ctx); + + throw new LxError('unknown-node', `unknown LX node: ${JSON.stringify(node).slice(0, 80)}`); +} + +function binNum(args: LxNode[], ctx: Ctx): [number, number] { + return [asNumber(evalNode(args[0]!, ctx)), asNumber(evalNode(args[1]!, ctx))]; +} + +/** Evaluate a body once per element with iteration bindings layered on env. */ +function evalBody(body: LxNode, ctx: Ctx, extra: Record): LxValue { + return evalNode(body, { ...ctx, env: { ...ctx.env, ...extra } }); +} + +function evalCall(name: string, argNodes: LxNode[], ctx: Ctx): LxValue { + const arity = ARITY[name]; + if (!arity) throw new LxError('unknown-call', `unknown std-lib call: ${name}`); + if (argNodes.length < arity[0] || argNodes.length > arity[1]) { + throw new LxError('arity', `${name} expects ${arity[1] === Infinity ? `≥${arity[0]}` : arity[0] === arity[1] ? arity[0] : `${arity[0]}–${arity[1]}`} args, got ${argNodes.length}`); + } + switch (name) { + case 'range': { + const len = asNumber(evalNode(argNodes[0]!, ctx)); + if (!Number.isInteger(len) || len < 0 || len > MAX_RANGE) throw new LxError('bounds', `range(${len}) out of bounds`); + chargeSize(ctx, len); + return Array.from({ length: len }, (_, i) => i); + } + case 'map': { + const coll = asList(evalNode(argNodes[0]!, ctx)); + if (coll.length > MAX_ITERATIONS) throw new LxError('bounds', 'map over too many items'); + chargeSize(ctx, coll.length); + const body = argNodes[1]!; + return coll.map((it, idx) => evalBody(body, ctx, { it, idx })); + } + case 'filter': { + const coll = asList(evalNode(argNodes[0]!, ctx)); + if (coll.length > MAX_ITERATIONS) throw new LxError('bounds', 'filter over too many items'); + chargeSize(ctx, coll.length); + const pred = argNodes[1]!; + return coll.filter((it, idx) => asBool(evalBody(pred, ctx, { it, idx }))); + } + case 'fold': { + const coll = asList(evalNode(argNodes[0]!, ctx)); + if (coll.length > MAX_ITERATIONS) throw new LxError('bounds', 'fold over too many items'); + burn(ctx, coll.length); + let acc = evalNode(argNodes[1]!, ctx); + const body = argNodes[2]!; + coll.forEach((it, idx) => { acc = evalBody(body, ctx, { acc, it, idx }); }); + return acc; + } + case 'len': { + const v = evalNode(argNodes[0]!, ctx); + if (Array.isArray(v)) return v.length; + if (typeof v === 'string') return v.length; + throw new LxError('type', 'len expects list or string'); + } + case 'min': return Math.min(...argNodes.map((a) => asNumber(evalNode(a, ctx)))); + case 'max': return Math.max(...argNodes.map((a) => asNumber(evalNode(a, ctx)))); + case 'clamp': { const [v, lo, hi] = argNodes.map((a) => asNumber(evalNode(a, ctx))) as [number, number, number]; return Math.min(hi, Math.max(lo, v)); } + case 'abs': return Math.abs(asNumber(evalNode(argNodes[0]!, ctx))); + case 'floor': return Math.floor(asNumber(evalNode(argNodes[0]!, ctx))); + case 'ceil': return Math.ceil(asNumber(evalNode(argNodes[0]!, ctx))); + case 'round': return Math.round(asNumber(evalNode(argNodes[0]!, ctx))); + case 'sqrt': { const x = asNumber(evalNode(argNodes[0]!, ctx)); if (x < 0) throw new LxError('bounds', 'sqrt of negative'); return Math.sqrt(x); } + case 'sign': return Math.sign(asNumber(evalNode(argNodes[0]!, ctx))); + case 'pow': { const [b, e] = binNum(argNodes, ctx); return fin(b ** e); } + case 'concat': { + const parts = argNodes.map((a) => evalNode(a, ctx)); + // require homogeneous args — all lists OR all strings (F9: no silent + // list→JSON-text coercion on a mixed call). + if (parts.every((p) => Array.isArray(p))) { + const total = (parts as LxValue[][]).reduce((s, p) => s + p.length, 0); + chargeSize(ctx, total); + return (parts as LxValue[][]).flat(); + } + if (parts.every((p) => typeof p === 'string')) { + const total = (parts as string[]).reduce((s, p) => s + p.length, 0); + chargeSize(ctx, total); + return (parts as string[]).join(''); + } + throw new LxError('type', 'concat expects all lists or all strings'); + } + case 'slice': { const list = asList(evalNode(argNodes[0]!, ctx)); const start = asNumber(evalNode(argNodes[1]!, ctx)); const end = argNodes[2] ? asNumber(evalNode(argNodes[2], ctx)) : list.length; return list.slice(start, end); } + case 'contains': { const list = asList(evalNode(argNodes[0]!, ctx)); const target = evalNode(argNodes[1]!, ctx); return list.some((x) => deepEqual(x, target)); } + case 'indexOf': { const list = asList(evalNode(argNodes[0]!, ctx)); const target = evalNode(argNodes[1]!, ctx); return list.findIndex((x) => deepEqual(x, target)); } + case 'keys': { const v = evalNode(argNodes[0]!, ctx); if (!isRecord(v)) throw new LxError('type', 'keys expects record'); return Object.keys(v); } + case 'values': { const v = evalNode(argNodes[0]!, ctx); if (!isRecord(v)) throw new LxError('type', 'values expects record'); return Object.values(v); } + case 'upper': return asString(evalNode(argNodes[0]!, ctx)).toUpperCase(); + case 'lower': return asString(evalNode(argNodes[0]!, ctx)).toLowerCase(); + case 'pad': { const s = asString(evalNode(argNodes[0]!, ctx)); const width = asNumber(evalNode(argNodes[1]!, ctx)); if (!Number.isInteger(width) || width < 0) throw new LxError('bounds', 'pad width must be a non-negative int'); chargeSize(ctx, width); const fill = argNodes[2] ? asString(evalNode(argNodes[2], ctx)) : ' '; return s.padStart(width, fill); } + case 'fmt': { const v = evalNode(argNodes[0]!, ctx); return typeof v === 'string' ? v : JSON.stringify(v); } + case 'random': return ctx.rng(); + case 'now': return ctx.now; + default: throw new LxError('unknown-call', `unknown std-lib call: ${name}`); + } +} + +/** Evaluate an LX expression to a value (used by `view`). */ +export function evaluate(node: LxNode, opts: EvalOptions): LxValue { + const ctx: Ctx = { + state: opts.state, + event: opts.event ?? {}, + env: {}, + now: opts.now ?? 0, + gas: { n: opts.gas ?? DEFAULT_GAS }, + depth: 0, + rng: mulberry32(opts.seed ?? 0), + }; + return evalNode(node, ctx); +} + +/** Run a transition `(state, event) -> state`. The result MUST be a record + * (the new state); otherwise the transition is invalid (§2.5). */ +export function runTransition(node: LxNode, opts: EvalOptions): StateValue { + const result = evaluate(node, opts); + if (!isRecord(result)) throw new LxError('type', 'a transition must return a state record'); + return result; +} diff --git a/middleware/packages/canvas-core/src/lx/types.ts b/middleware/packages/canvas-core/src/lx/types.ts new file mode 100644 index 00000000..f99e7f7f --- /dev/null +++ b/middleware/packages/canvas-core/src/lx/types.ts @@ -0,0 +1,76 @@ +/** + * omadia-canvas-protocol/1.1 — Lume Expressions (LX) TypeScript types. + * Mirrors schema/lx-ast.schema.json and schema/lumen.schema.json. Where these + * and the schema disagree, the schema wins (the validator is the contract). + */ + +/** A runtime LX value: number (int|number), bool, string, list, record. */ +export type LxValue = number | boolean | string | LxValue[] | { [k: string]: LxValue }; + +/** The closed, serialisable state record a Lumen carries (§1.1). */ +export type StateValue = { [k: string]: LxValue }; + +/** A JSON AST node (§2.2). Validated structurally by the schema; this is the + * permissive TS shape the interpreter walks. */ +export type LxNode = + | { lit: LxValue } + | { state: string; at?: [LxNode, LxNode] } + | { event: string } + | { var: string } + | { let: Record; in: LxNode } + | { '+': LxNode[] } | { '-': LxNode[] } | { '*': LxNode[] } | { '/': [LxNode, LxNode] } | { mod: [LxNode, LxNode] } + | { '>': [LxNode, LxNode] } | { '>=': [LxNode, LxNode] } | { '<': [LxNode, LxNode] } | { '<=': [LxNode, LxNode] } | { '==': [LxNode, LxNode] } | { '!=': [LxNode, LxNode] } + | { and: LxNode[] } | { or: LxNode[] } | { not: LxNode } + | { if: LxNode; then: LxNode; else: LxNode } + | { match: LxNode; cases: { when: LxNode; then: LxNode }[]; else: LxNode } + | { record: Record } + | { list: LxNode[] } + | { get: LxNode; key: LxNode } + | { set: Record } + | { call: StdlibName; args: LxNode[] }; + +export type StdlibName = + | 'map' | 'filter' | 'fold' | 'range' | 'len' | 'min' | 'max' | 'clamp' + | 'abs' | 'floor' | 'ceil' | 'round' | 'sqrt' | 'sign' | 'pow' + | 'concat' | 'slice' | 'contains' | 'indexOf' | 'keys' | 'values' + | 'upper' | 'lower' | 'pad' | 'fmt' + | 'random' | 'now'; + +/** Host-seeded, bounded evaluation context (§0.3, §2.4). Identical + * (state, event, seed, now) ⇒ byte-identical result on every machine. */ +export interface EvalOptions { + state: StateValue; + event?: Record; + /** host seed for `random()` — same seed ⇒ same sequence (replay/share). */ + seed?: number; + /** host clock value returned by `now()` — fixed per evaluation. */ + now?: number; + /** instruction budget; default DEFAULT_GAS. Over budget ⇒ LxError('gas'). */ + gas?: number; +} + +export type LxErrorCode = 'gas' | 'type' | 'unknown-node' | 'unknown-call' | 'bad-path' | 'bounds' | 'div-zero' | 'arity'; + +export class LxError extends Error { + constructor( + public code: LxErrorCode, + message: string, + ) { + super(message); + this.name = 'LxError'; + } +} + +/** lumens-spec.md §2.4 — spike-tunable initial defaults. */ +export const DEFAULT_GAS = 50_000; +/** §0.2 — bounded iteration: a single collection op may not exceed this. */ +export const MAX_ITERATIONS = 100_000; +/** §2.3 — `range(n)` upper bound (gas also bounds it). */ +export const MAX_RANGE = 100_000; +/** §0.2 — hard ceiling on any single produced value (list length / string + * length / record keys). Stops size-amplifying ops (concat-doubling, pad, + * map-over-range) from exploding memory while gas stays low. */ +export const MAX_VALUE_SIZE = 1_000_000; +/** §0.2 — max AST recursion depth; a deeper tree halts with LxError before it + * can overflow the JS stack (which would be an uncatchable RangeError). */ +export const MAX_DEPTH = 1_024; diff --git a/middleware/packages/canvas-core/src/lx/validate.ts b/middleware/packages/canvas-core/src/lx/validate.ts new file mode 100644 index 00000000..76e7952d --- /dev/null +++ b/middleware/packages/canvas-core/src/lx/validate.ts @@ -0,0 +1,125 @@ +/** + * omadia-canvas-protocol/1.1 — LX static semantic validator (lumens-spec.md §2.5). + * + * The structural whitelist lives in the JSON schema (validateLumen). This layer + * adds the semantics JSON Schema cannot express: + * - every EventBinding.run names a declared transition (§4), + * - every `state`/`set` path resolves against the declared state schema (§1.1), + * - every `{var}` read is lexically bound (a `let`, or an iteration binding), + * - tick/timer bindings declare the field their `on` requires. + * A Lumen is accepted only if BOTH layers pass; either failure ⇒ surface_error. + */ +import { MAX_DEPTH, type LxNode } from './types.js'; + +export interface SemanticResult { + ok: boolean; + errors: string[]; +} + +type StateLeaf = { type: string; fields?: Record; of?: StateLeaf }; +type StateSchema = Record; + +interface LumenShape { + state: StateSchema; + transitions: Record; + view: LxNode; + events: { on: string; run: string; rate?: number; everyMs?: number; key?: string }[]; +} + +/** Does a dotted path resolve through the declared state schema? */ +function pathResolves(schema: StateSchema, path: string): boolean { + const segs = path.split('.'); + let leaf: StateLeaf | undefined = schema[segs[0]!]; + for (let i = 1; i < segs.length && leaf; i++) { + if (leaf.type === 'record' && leaf.fields) leaf = leaf.fields[segs[i]!]; + else if ((leaf.type === 'list' || leaf.type === 'grid') && leaf.of) leaf = leaf.of; // index/sub-field + else return false; + } + return leaf !== undefined; +} + +/** Walk an LX node, collecting path + var-scope errors. `scope` is the set of + * lexically-bound names in effect at this node. */ +function walk(node: LxNode, schema: StateSchema, scope: Set, errors: string[], depth = 0): void { + if (node === null || typeof node !== 'object') return; + // bound recursion (F4) — a deeply-nested tree would overflow the stack on the + // validator's own pass before the interpreter ever ran. + if (depth > MAX_DEPTH) { + errors.push('expression nesting too deep'); + return; + } + const d = depth + 1; + const n = node as Record; + + if ('state' in n && typeof n.state === 'string') { + if (!pathResolves(schema, n.state)) errors.push(`state path '${n.state}' does not resolve against the state schema`); + if (Array.isArray(n.at)) for (const e of n.at) walk(e as LxNode, schema, scope, errors, d); + return; + } + if ('var' in n && typeof n.var === 'string') { + if (!scope.has(n.var)) errors.push(`unbound var '${n.var}'`); + return; + } + if ('let' in n && n.let && typeof n.let === 'object') { + const binding = n.let as Record; + const [name] = Object.keys(binding); + walk(binding[name!]!, schema, scope, errors, d); + walk(n.in as LxNode, schema, new Set([...scope, name!]), errors, d); + return; + } + if ('set' in n && n.set && typeof n.set === 'object') { + for (const [path, e] of Object.entries(n.set as Record)) { + if (!pathResolves(schema, path)) errors.push(`set path '${path}' does not resolve against the state schema`); + walk(e, schema, scope, errors, d); + } + return; + } + if ('call' in n && Array.isArray(n.args)) { + const fn = n.call; + // map/filter bind it/idx in arg[1]; fold binds acc/it/idx in arg[2]. The + // interpreter binds `acc` ONLY for fold, so the validator must too (F6) — + // else {var:acc} in a map body passes validation but throws at runtime. + const mapScope = new Set([...scope, 'it', 'idx']); + const foldScope = new Set([...scope, 'it', 'idx', 'acc']); + n.args.forEach((arg, i) => { + const bodyScope = + (fn === 'map' || fn === 'filter') && i === 1 ? mapScope : fn === 'fold' && i === 2 ? foldScope : scope; + walk(arg as LxNode, schema, bodyScope, errors, d); + }); + return; + } + + // generic recursion over any nested node/array values + for (const value of Object.values(n)) { + if (Array.isArray(value)) for (const v of value) walk(v as LxNode, schema, scope, errors, d); + else if (value && typeof value === 'object') walk(value as LxNode, schema, scope, errors, d); + } +} + +/** Validate the semantic layer of an already structurally-valid Lumen. */ +export function validateLumenSemantics(lumen: unknown): SemanticResult { + const errors: string[] = []; + const l = lumen as Partial; + const schema = (l.state ?? {}) as StateSchema; + const transitions = (l.transitions ?? {}) as Record; + const transitionNames = new Set(Object.keys(transitions)); + + for (const [name, body] of Object.entries(transitions)) { + const sub: string[] = []; + walk(body, schema, new Set(), sub); + for (const e of sub) errors.push(`transition '${name}': ${e}`); + } + if (l.view) { + const sub: string[] = []; + walk(l.view, schema, new Set(), sub); + for (const e of sub) errors.push(`view: ${e}`); + } + for (const ev of l.events ?? []) { + if (!transitionNames.has(ev.run)) errors.push(`event '${ev.on}' runs undeclared transition '${ev.run}'`); + if (ev.on === 'tick' && ev.rate === undefined) errors.push(`tick event must declare a 'rate'`); + if (ev.on === 'timer' && ev.everyMs === undefined) errors.push(`timer event must declare 'everyMs'`); + if (ev.on === 'key' && ev.key === undefined) errors.push(`key event must declare a 'key'`); + } + + return { ok: errors.length === 0, errors }; +} diff --git a/middleware/packages/canvas-core/src/validator.ts b/middleware/packages/canvas-core/src/validator.ts index 6f684f5d..a054ff5f 100644 --- a/middleware/packages/canvas-core/src/validator.ts +++ b/middleware/packages/canvas-core/src/validator.ts @@ -5,8 +5,12 @@ import { validateSurfaceEvent as surfaceValidate, validateTree as treeValidate, + validateLumen as lumenValidate, + validateScene as sceneValidate, + validateLxNode as lxNodeValidate, type StandaloneValidate, } from './validators.generated.mjs'; +import { validateLumenSemantics } from './lx/validate.js'; export interface ValidationResult { ok: boolean; @@ -30,3 +34,34 @@ export function validateTree(tree: unknown): ValidationResult { export function validateSurfaceEvent(event: unknown): ValidationResult { return run(surfaceValidate, event); } + +// ── omadia-canvas-protocol/1.1 — Lumens (Live Interactivity) ── +// Structural whitelist parsers. The L1 static validator (lx/validate.ts) layers +// on the semantic checks JSON Schema cannot express: state/event path +// resolution, gas + bounded-iteration proof, transition/event coherence. + +/** Validate a full Lumen (state schema + transitions + view + events + …). */ +export function validateLumen(lumen: unknown): ValidationResult { + return run(lumenValidate, lumen); +} + +/** Validate a `scene` primitive (draw-list, theme-token styling). */ +export function validateScene(scene: unknown): ValidationResult { + return run(sceneValidate, scene); +} + +/** Validate a single LX AST node against the §2.2/§2.3 whitelist. */ +export function validateLxNode(node: unknown): ValidationResult { + return run(lxNodeValidate, node); +} + +/** The COMPLETE Lumen gate (§1, §2.5): structural whitelist AND the semantic + * layer (path resolution, var scoping, transition/event coherence). Library + * consumers should call this, never `validateLumen` alone — the structural + * check by itself accepts Lumens the interpreter would crash or misbehave on. */ +export function validateLumenFull(lumen: unknown): ValidationResult { + const structural = run(lumenValidate, lumen); + if (!structural.ok) return structural; + const semantic = validateLumenSemantics(lumen); + return semantic.ok ? { ok: true, errors: null } : { ok: false, errors: semantic.errors.join('; ') }; +} diff --git a/middleware/packages/canvas-core/src/validators.generated.d.mts b/middleware/packages/canvas-core/src/validators.generated.d.mts index 81c5db8c..6f7a2475 100644 --- a/middleware/packages/canvas-core/src/validators.generated.d.mts +++ b/middleware/packages/canvas-core/src/validators.generated.d.mts @@ -8,3 +8,7 @@ export type StandaloneValidate = ((data: unknown) => boolean) & { export declare const validateTree: StandaloneValidate; export declare const validateSurfaceEvent: StandaloneValidate; +// omadia-canvas-protocol/1.1 — Lumens +export declare const validateLumen: StandaloneValidate; +export declare const validateScene: StandaloneValidate; +export declare const validateLxNode: StandaloneValidate; diff --git a/middleware/packages/canvas-core/test/capabilities.test.ts b/middleware/packages/canvas-core/test/capabilities.test.ts new file mode 100644 index 00000000..2d2814d8 --- /dev/null +++ b/middleware/packages/canvas-core/test/capabilities.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { + classifyEffect, + reconcileDeclared, + BrokerLimiter, + DEFAULT_LIMITS, + contentId, + ContentAddressedStore, + consentForManifest, + manifestIsImportable, +} from '../src/capabilities/index.js'; + +describe('effect classification (§6)', () => { + it('local/internal/external-effect base classes', () => { + expect(classifyEffect('persist').effect).toBe('internal'); + expect(classifyEffect('clipboard')).toMatchObject({ effect: 'external-effect', needsConfirmation: true }); + expect(classifyEffect('tiles').needsConfirmation).toBe(false); + }); + it('state-derived egress escalates to external-effect (confirmed)', () => { + expect(classifyEffect('fetch', { stateDerived: false })).toMatchObject({ effect: 'internal', needsConfirmation: false }); + expect(classifyEffect('fetch', { stateDerived: true })).toMatchObject({ effect: 'external-effect', needsConfirmation: true }); + }); + it('pre-approval at grant keeps state-derived egress internal', () => { + expect(classifyEffect('writeData', { stateDerived: true, preApproved: true }).effect).toBe('internal'); + }); + it('Tier-2 may upgrade a declared class, never downgrade it', () => { + expect(reconcileDeclared('internal', 'external-effect')).toBe('external-effect'); + expect(reconcileDeclared('external-effect', 'internal')).toBe('external-effect'); // no downgrade + }); +}); + +describe('broker egress bounds (§6 anti-DoS/anti-cost)', () => { + const limits = { ...DEFAULT_LIMITS, fetch: { ratePerWindow: 2, windowMs: 1000, quota: 3, maxInFlight: 2 } }; + + it('admits within rate, then rejects on rate', () => { + const b = new BrokerLimiter(limits); + expect(b.admit('fetch', 'k1', 0)).toMatchObject({ ok: true }); + b.settle('fetch', 'k1'); + expect(b.admit('fetch', 'k2', 10)).toMatchObject({ ok: true }); + b.settle('fetch', 'k2'); + expect(b.admit('fetch', 'k3', 20)).toMatchObject({ ok: false, reason: 'rate' }); + }); + it('rolling window frees rate after windowMs', () => { + const b = new BrokerLimiter(limits); + b.admit('fetch', 'a', 0); b.settle('fetch', 'a'); + b.admit('fetch', 'b', 10); b.settle('fetch', 'b'); + expect(b.admit('fetch', 'c', 1100).ok).toBe(true); // window rolled past the first two + }); + it('coalesces identical in-flight calls (idempotent dedup)', () => { + const b = new BrokerLimiter(limits); + expect(b.admit('fetch', 'same', 0)).toEqual({ ok: true, deduped: false }); + expect(b.admit('fetch', 'same', 1)).toEqual({ ok: true, deduped: true }); // no extra rate/quota spend + }); + it('backpressure when max-in-flight is reached', () => { + const b = new BrokerLimiter({ ...limits, fetch: { ratePerWindow: 99, windowMs: 1000, quota: 99, maxInFlight: 1 } }); + expect(b.admit('fetch', 'x', 0).ok).toBe(true); // in flight + expect(b.admit('fetch', 'y', 1)).toMatchObject({ ok: false, reason: 'backpressure' }); + b.settle('fetch', 'x'); + expect(b.admit('fetch', 'y', 2).ok).toBe(true); + }); + it('lifetime quota caps total calls (cost ceiling)', () => { + const b = new BrokerLimiter(limits); // quota 3 + for (let i = 0; i < 3; i++) { expect(b.admit('fetch', `k${i}`, i * 600).ok).toBe(true); b.settle('fetch', `k${i}`); } + expect(b.admit('fetch', 'k4', 3000)).toMatchObject({ ok: false, reason: 'quota' }); + expect(b.remaining('fetch')).toBe(0); + }); +}); + +describe('content-addressed assets (§6.1 never-stale by construction)', () => { + it('same bytes → same id; different bytes → different id', () => { + expect(contentId('pixel', 'hello')).toBe(contentId('pixel', 'hello')); + expect(contentId('pixel', 'hello')).not.toBe(contentId('pixel', 'world')); + expect(contentId('pixel', 'x')).toMatch(/^pixel-[0-9a-f]{16}$/); + }); + it('rejects an invalid kind', () => { + expect(() => contentId('PIXEL', 'x')).toThrow(); + }); + it('store dedups identical content and supports explicit invalidation', () => { + const s = new ContentAddressedStore(); + const id1 = s.put('struct', 'data-A'); + const id2 = s.put('struct', 'data-A'); + expect(id1).toBe(id2); + expect(s.size).toBe(1); + expect(s.has(id1)).toBe(true); + s.invalidate(id1); + expect(s.has(id1)).toBe(false); + }); + it('gc removes only expired AND unreferenced entries', () => { + const s = new ContentAddressedStore(); + const live = s.put('struct', 'keep', '2020-01-01T00:00:00Z'); // expired + s.retain(live); + const dead = s.put('struct', 'drop', '2020-01-01T00:00:00Z'); // expired, unreferenced + const fresh = s.put('struct', 'fresh', '2999-01-01T00:00:00Z'); // not expired + const removed = s.gc('2026-06-15T00:00:00Z'); + expect(removed).toEqual([dead]); + expect(s.has(live)).toBe(true); // referenced + expect(s.has(fresh)).toBe(true); // not expired + }); +}); + +describe('import consent (§9 consent before first run)', () => { + it('flags external-effect capabilities for explicit consent, shows all', () => { + const report = consentForManifest([{ cap: 'tiles' }, { cap: 'fetch' }, { cap: 'clipboard' }, { cap: 'persist' }]); + expect(report.shown.map((s) => s.cap).sort()).toEqual(['clipboard', 'fetch', 'persist', 'tiles']); + expect(report.requiresConsent.sort()).toEqual(['clipboard', 'fetch']); // egress + external; tiles/persist internal + expect(report.unknown).toEqual([]); + }); + it('an unknown capability makes the Lumen un-importable (whitelist)', () => { + const report = consentForManifest([{ cap: 'tiles' }, { cap: 'exec' }]); + expect(report.unknown).toEqual(['exec']); + expect(manifestIsImportable([{ cap: 'tiles' }, { cap: 'exec' }])).toBe(false); + expect(manifestIsImportable([{ cap: 'tiles' }])).toBe(true); + }); +}); diff --git a/middleware/packages/canvas-core/test/lumen.test.ts b/middleware/packages/canvas-core/test/lumen.test.ts new file mode 100644 index 00000000..6d72ec55 --- /dev/null +++ b/middleware/packages/canvas-core/test/lumen.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { validateLumen, validateScene, validateLxNode } from '../src/validator.js'; +import type { ValidationResult } from '../src/validator.js'; + +// omadia-canvas-protocol/1.1 — Lumens conformance (lumens-spec.md §14). +// Accept/reject fixtures are the conformance contract for the structural +// whitelist parsers. The L1 static validator adds the semantic checks JSON +// Schema cannot express (tested separately in lx-interpreter.test.ts). +const fixturesRoot = join(dirname(fileURLToPath(import.meta.url)), '../fixtures'); + +type Validate = (v: unknown) => ValidationResult; + +function fixtures(sub: string): string[] { + return readdirSync(join(fixturesRoot, sub)) + .filter((f) => f.endsWith('.json')) + .map((f) => join(sub, f)); +} + +function suite(name: string, validate: Validate) { + describe(`${name} — accept fixtures`, () => { + const accept = fixtures(`${name}/accept`); + it('has fixtures', () => expect(accept.length).toBeGreaterThan(0)); + for (const file of accept) { + it(`accepts ${file}`, () => { + const node: unknown = JSON.parse(readFileSync(join(fixturesRoot, file), 'utf8')); + const result = validate(node); + expect(result.errors).toBeNull(); + expect(result.ok).toBe(true); + }); + } + }); + + describe(`${name} — reject fixtures`, () => { + const reject = fixtures(`${name}/reject`); + it('has fixtures', () => expect(reject.length).toBeGreaterThan(0)); + for (const file of reject) { + it(`rejects ${file}`, () => { + const node: unknown = JSON.parse(readFileSync(join(fixturesRoot, file), 'utf8')); + expect(validate(node).ok).toBe(false); + }); + } + }); +} + +suite('lx', validateLxNode); +suite('scene', validateScene); +suite('lumen', validateLumen); + +describe('Lumen whitelist parser — targeted', () => { + it('rejects arbitrary code disguised as a literal-keyed node', () => { + expect(validateLxNode({ eval: '1+1' }).ok).toBe(false); + }); + it('a scene fill must be a theme token, never a raw colour', () => { + expect( + validateScene({ type: 'scene', width: 4, height: 4, draw: [{ kind: 'rect', x: 0, y: 0, w: 1, h: 1, fill: 'rgb(0,0,0)' }] }).ok, + ).toBe(false); + }); + it('a Lumen never partially validates — one bad field rejects the whole', () => { + expect( + validateLumen({ + type: 'lumen', id: 'x', state: {}, transitions: {}, view: { lit: 1 }, events: [], + cadence: { tick: 999 }, + }).ok, + ).toBe(false); + }); +}); diff --git a/middleware/packages/canvas-core/test/lx-interpreter.test.ts b/middleware/packages/canvas-core/test/lx-interpreter.test.ts new file mode 100644 index 00000000..c4dd3577 --- /dev/null +++ b/middleware/packages/canvas-core/test/lx-interpreter.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest'; +import { evaluate, runTransition, LxError, type LxNode, type StateValue } from '../src/lx/index.js'; +import { validateLumenSemantics } from '../src/lx/validate.js'; + +const ev = (node: LxNode, state: StateValue = {}, extra = {}) => + evaluate(node, { state, ...extra }); + +describe('LX interpreter — core evaluation', () => { + it('literals, arithmetic, nesting', () => { + expect(ev({ '+': [{ lit: 1 }, { '*': [{ lit: 2 }, { lit: 3 }] }] })).toBe(7); + expect(ev({ '-': [{ lit: 10 }, { lit: 3 }, { lit: 2 }] })).toBe(5); + expect(ev({ mod: [{ lit: 7 }, { lit: 3 }] })).toBe(1); + }); + + it('reads state by dotted path and grid by [x,y]', () => { + expect(ev({ state: 'pos.x' }, { pos: { x: 5, y: 9 } })).toBe(5); + const board = [[0, 0, 0], [0, 7, 0]]; + expect(ev({ state: 'board', at: [{ lit: 1 }, { lit: 1 }] }, { board })).toBe(7); + }); + + it('reads event fields (missing ⇒ 0)', () => { + expect(ev({ event: 'key' }, {}, { event: { key: 42 } })).toBe(42); + expect(ev({ event: 'absent' }, {}, { event: {} })).toBe(0); + }); + + it('total conditionals: if / match', () => { + expect(ev({ if: { '>': [{ lit: 2 }, { lit: 1 }] }, then: { lit: 'a' }, else: { lit: 'b' } })).toBe('a'); + expect(ev({ match: { lit: 'x' }, cases: [{ when: { lit: 'x' }, then: { lit: 1 } }], else: { lit: 0 } })).toBe(1); + expect(ev({ match: { lit: 'z' }, cases: [{ when: { lit: 'x' }, then: { lit: 1 } }], else: { lit: 0 } })).toBe(0); + }); + + it('let / var lexical binding', () => { + expect(ev({ let: { n: { lit: 21 } }, in: { '*': [{ var: 'n' }, { lit: 2 }] } })).toBe(42); + }); + + it('deep equality for == / contains', () => { + expect(ev({ '==': [{ lit: [1, 2] }, { lit: [1, 2] }] })).toBe(true); + expect(ev({ call: 'contains', args: [{ lit: [1, 2, 3] }, { lit: 2 }] })).toBe(true); + }); +}); + +describe('LX interpreter — std-lib & iteration (no lambdas)', () => { + it('range / map / filter / fold with it/idx/acc bindings', () => { + expect(ev({ call: 'range', args: [{ lit: 4 }] })).toEqual([0, 1, 2, 3]); + expect(ev({ call: 'map', args: [{ lit: [1, 2, 3] }, { '*': [{ var: 'it' }, { lit: 10 }] }] })).toEqual([10, 20, 30]); + expect(ev({ call: 'filter', args: [{ lit: [1, 2, 3, 4] }, { '>': [{ var: 'it' }, { lit: 2 }] }] })).toEqual([3, 4]); + expect(ev({ call: 'fold', args: [{ lit: [1, 2, 3, 4] }, { lit: 0 }, { '+': [{ var: 'acc' }, { var: 'it' }] }] })).toBe(10); + }); + it('get projects record fields and list elements', () => { + expect(ev({ get: { lit: { x: 10, y: 20 } }, key: { lit: 'y' } })).toBe(20); + expect(ev({ get: { lit: [5, 6, 7] }, key: { lit: 2 } })).toBe(7); + }); + it('get enables map-over-records reading a field of `it`', () => { + const node: LxNode = { call: 'map', args: [{ state: 'rows' }, { get: { var: 'it' }, key: { lit: 'n' } }] }; + expect(ev(node, { rows: [{ n: 1 }, { n: 2 }, { n: 3 }] })).toEqual([1, 2, 3]); + }); + it('get on a missing key / out-of-range index is a typed error', () => { + expect(() => ev({ get: { lit: { a: 1 } }, key: { lit: 'b' } })).toThrow(/no key/); + expect(() => ev({ get: { lit: [1] }, key: { lit: 9 } })).toThrow(/out of range/); + }); + + it('string + math ops', () => { + expect(ev({ call: 'upper', args: [{ lit: 'hi' }] })).toBe('HI'); + expect(ev({ call: 'clamp', args: [{ lit: 12 }, { lit: 0 }, { lit: 9 }] })).toBe(9); + expect(ev({ call: 'pad', args: [{ lit: '7' }, { lit: 3 }, { lit: '0' }] })).toBe('007'); + }); +}); + +describe('LX interpreter — determinism (replay / share)', () => { + it('same seed ⇒ identical random sequence', () => { + const node: LxNode = { list: [{ call: 'random', args: [] }, { call: 'random', args: [] }] }; + const a = evaluate(node, { state: {}, seed: 123 }); + const b = evaluate(node, { state: {}, seed: 123 }); + const c = evaluate(node, { state: {}, seed: 124 }); + expect(a).toEqual(b); + expect(a).not.toEqual(c); + }); + it('now() returns the host-provided clock', () => { + expect(evaluate({ call: 'now', args: [] }, { state: {}, now: 1000 })).toBe(1000); + }); +}); + +describe('LX interpreter — bounded & total (cannot hang the host)', () => { + it('exhausting gas throws LxError(gas)', () => { + // a huge range burns gas per element + expect(() => evaluate({ call: 'range', args: [{ lit: 90000 }] }, { state: {}, gas: 1000 })) + .toThrowError(LxError); + }); + it('range beyond MAX_RANGE is rejected', () => { + expect(() => ev({ call: 'range', args: [{ lit: 9_000_000 }] })).toThrow(/bounds/); + }); + it('division by zero is a typed error, not NaN', () => { + expect(() => ev({ '/': [{ lit: 1 }, { lit: 0 }] })).toThrow(/zero/); + }); + it('unbound var is a typed error', () => { + expect(() => ev({ var: 'ghost' })).toThrow(LxError); + }); + + it('rejects prototype-pollution via set path / record key / state read', () => { + expect(() => runTransition({ set: { '__proto__.polluted': { lit: 1 } } }, { state: {} })).toThrow(/forbidden/); + expect(() => runTransition({ set: { 'constructor.x': { lit: 1 } } }, { state: {} })).toThrow(/forbidden/); + // real LX arrives via JSON.parse, which (unlike a JS object literal) keeps + // `__proto__` as an OWN key — the case the guard must catch. + expect(() => ev(JSON.parse('{ "record": { "__proto__": { "lit": 1 } } }') as LxNode)).toThrow(/forbidden/); + expect(() => ev({ state: '__proto__' }, {})).toThrow(/forbidden/); + // and the global prototype is untouched after an attempt + try { runTransition({ set: { '__proto__.x': { lit: 9 } } }, { state: {} }); } catch { /* expected */ } + expect(({} as Record).x).toBeUndefined(); + }); +}); + +describe('LX interpreter — hardening (adversarial review fixes)', () => { + it('F1: concat-doubling cannot explode memory under low gas (size is charged)', () => { + // 22 nested doublings would be 4M elements; size-charging must halt it. + let node: LxNode = { lit: [0] }; + for (let i = 0; i < 28; i++) node = { call: 'concat', args: [node, node] }; + expect(() => evaluate(node, { state: {}, gas: 50_000 })).toThrow(/gas|cap/); + }); + it('F1: pad cannot allocate a huge string cheaply', () => { + expect(() => ev({ call: 'pad', args: [{ lit: 'x' }, { lit: 50_000_000 }] })).toThrow(/cap|gas/); + }); + it('F1: map producing a giant result is bounded', () => { + const big = { lit: Array.from({ length: 1000 }, (_, i) => i) }; + expect(() => evaluate({ call: 'map', args: [{ call: 'range', args: [{ lit: 100000 }] }, big] }, { state: {}, gas: 50_000 })).toThrow(/gas|cap/); + }); + it('F3: a missing child node is a typed LxError, never a raw TypeError', () => { + // simulate a node that slipped past the schema (no `else`); force the else + // branch so the missing child is actually evaluated. + expect(() => ev({ if: { lit: false }, then: { lit: 1 } } as unknown as LxNode)).toThrow(LxError); + }); + it('F4: a pathologically deep tree halts with LxError, not a stack overflow', () => { + let node: LxNode = { lit: true }; + for (let i = 0; i < 5000; i++) node = { not: node }; + expect(() => ev(node)).toThrow(LxError); + }); + it('F5: set cannot invent a new top-level state key', () => { + expect(() => runTransition({ set: { ghost: { lit: 1 } } }, { state: { real: 0 } })).toThrow(/not a declared state key/); + }); + it('F7: stdlib arity is enforced', () => { + expect(() => ev({ call: 'min', args: [] })).toThrow(/expects/); + expect(() => ev({ call: 'clamp', args: [{ lit: 5 }] })).toThrow(/expects/); + expect(() => ev({ call: 'pow', args: [{ lit: 2 }] })).toThrow(/expects/); + }); + it('F8: non-finite arithmetic results halt (determinism on JSON round-trip)', () => { + expect(() => ev({ call: 'pow', args: [{ lit: 10 }, { lit: 1000 }] })).toThrow(/non-finite/); + expect(() => ev({ '/': [{ lit: 1e308 }, { lit: 1e-308 }] })).toThrow(/non-finite/); + }); + it('F8: -0 is normalised to 0', () => { + expect(Object.is(ev({ '*': [{ lit: -1 }, { lit: 0 }] }), 0)).toBe(true); + }); + it('F9: concat rejects a mixed list/string call', () => { + expect(() => ev({ call: 'concat', args: [{ lit: [1, 2] }, { lit: 'x' }] })).toThrow(/all lists or all strings/); + }); +}); + +describe('LX transitions — functional state update', () => { + it('set returns a NEW state; original is untouched', () => { + const state = { count: 1, pos: { x: 0 } }; + const next = runTransition({ set: { count: { '+': [{ state: 'count' }, { lit: 1 }] } } }, { state }); + expect(next.count).toBe(2); + expect(state.count).toBe(1); // immutability + }); + it('nested set clones, does not mutate the original nested record', () => { + const state = { pos: { x: 0, y: 0 } }; + const next = runTransition({ set: { 'pos.x': { lit: 9 } } }, { state }) as { pos: { x: number; y: number } }; + expect(next.pos.x).toBe(9); + expect(next.pos.y).toBe(0); + expect(state.pos.x).toBe(0); + }); + it('a transition that does not return a record is invalid', () => { + expect(() => runTransition({ lit: 5 }, { state: {} })).toThrow(/state record/); + }); +}); + +describe('LX static semantic validator', () => { + const base = { + state: { count: { type: 'int', init: 0 }, pos: { type: 'record', fields: { x: { type: 'int', init: 0 } }, init: {} } }, + transitions: { inc: { set: { count: { '+': [{ state: 'count' }, { lit: 1 }] } } } }, + view: { lit: 1 }, + events: [{ on: 'tap', run: 'inc' }], + }; + it('accepts a coherent Lumen', () => { + expect(validateLumenSemantics(base)).toMatchObject({ ok: true }); + }); + it('flags an event running an undeclared transition', () => { + const r = validateLumenSemantics({ ...base, events: [{ on: 'tap', run: 'nope' }] }); + expect(r.ok).toBe(false); + expect(r.errors.join()).toMatch(/undeclared transition 'nope'/); + }); + it('flags a state path that does not resolve', () => { + const r = validateLumenSemantics({ ...base, transitions: { inc: { set: { ghost: { lit: 1 } } } } }); + expect(r.ok).toBe(false); + expect(r.errors.join()).toMatch(/does not resolve/); + }); + it('resolves a nested record path', () => { + const r = validateLumenSemantics({ ...base, transitions: { mv: { set: { 'pos.x': { lit: 3 } } } }, events: [{ on: 'tap', run: 'mv' }] }); + expect(r.ok).toBe(true); + }); + it('flags an unbound var in a transition body', () => { + const r = validateLumenSemantics({ ...base, transitions: { inc: { set: { count: { var: 'x' } } } } }); + expect(r.ok).toBe(false); + expect(r.errors.join()).toMatch(/unbound var 'x'/); + }); + it('F6: acc is NOT in scope in a map body (only fold binds it)', () => { + const r = validateLumenSemantics({ ...base, transitions: { m: { set: { count: { call: 'map', args: [{ lit: [1] }, { var: 'acc' }] } } } }, events: [{ on: 'tap', run: 'm' }] }); + expect(r.ok).toBe(false); + expect(r.errors.join()).toMatch(/unbound var 'acc'/); + }); + it('F6: acc IS in scope in a fold body', () => { + const r = validateLumenSemantics({ ...base, transitions: { f: { set: { count: { call: 'fold', args: [{ lit: [1] }, { lit: 0 }, { '+': [{ var: 'acc' }, { var: 'it' }] }] } } } }, events: [{ on: 'tap', run: 'f' }] }); + expect(r.ok).toBe(true); + }); + it('it/idx are in scope inside a map body', () => { + const r = validateLumenSemantics({ + ...base, + state: { xs: { type: 'list', of: { type: 'int', init: 0 }, maxLen: 8, init: [] } }, + transitions: { dbl: { set: { xs: { call: 'map', args: [{ state: 'xs' }, { '*': [{ var: 'it' }, { lit: 2 }] }] } } } }, + events: [{ on: 'tap', run: 'dbl' }], + }); + expect(r.ok).toBe(true); + }); +}); diff --git a/middleware/packages/canvas-core/test/presets.test.ts b/middleware/packages/canvas-core/test/presets.test.ts new file mode 100644 index 00000000..659386a2 --- /dev/null +++ b/middleware/packages/canvas-core/test/presets.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { canonicalize, presetId, shapeSignature, PresetRegistry, forkPreset } from '../src/capabilities/presets.js'; + +const lumenA = { + type: 'lumen', id: 'counter', + state: { count: { type: 'int', init: 0 } }, + transitions: { inc: { set: { count: { '+': [{ state: 'count' }, { lit: 1 }] } } } }, + view: { record: { type: { lit: 'scene' } } }, + events: [{ on: 'tap', run: 'inc' }], +}; + +describe('canonicalize + presetId (§8 content-addressed)', () => { + it('is key-order independent', () => { + expect(canonicalize({ b: 1, a: 2 })).toBe(canonicalize({ a: 2, b: 1 })); + }); + it('same spec → same id; different spec → different id', () => { + expect(presetId(lumenA)).toBe(presetId({ ...lumenA })); + expect(presetId(lumenA)).toMatch(/^preset-[0-9a-f]{16}$/); + expect(presetId(lumenA)).not.toBe(presetId({ ...lumenA, id: 'other', state: { count: { type: 'int', init: 5 } } })); + }); + it('the preset provenance field does NOT affect the content id', () => { + expect(presetId(lumenA)).toBe(presetId({ ...lumenA, preset: { id: 'preset-aaaaaaaaaaaaaaaa' } })); + }); +}); + +describe('resolve-then-generate (§8)', () => { + it('exact content hit → instantiate', () => { + const reg = new PresetRegistry(); + const id = reg.register(lumenA, 'tenant'); + expect(reg.resolve(lumenA)).toEqual({ kind: 'exact', id, scope: 'tenant' }); + }); + it('structural near-hit → fork+patch candidate, highest scope wins', () => { + const reg = new PresetRegistry(); + reg.register({ ...lumenA, state: { count: { type: 'int', init: 9 } } }, 'user'); + const firstParty = reg.register({ ...lumenA, state: { count: { type: 'int', init: 3 } } }, 'first-party'); + const r = reg.resolve({ ...lumenA, state: { count: { type: 'int', init: 0 } } }); + expect(r.kind).toBe('near'); + if (r.kind === 'near') expect(r).toMatchObject({ id: firstParty, scope: 'first-party' }); + }); + it('miss → cold-author', () => { + const reg = new PresetRegistry(); + reg.register(lumenA); + expect(reg.resolve({ type: 'lumen', id: 'x', state: { totally: { type: 'bool', init: false } }, transitions: {}, view: { lit: 1 }, events: [] }).kind).toBe('miss'); + }); +}); + +describe('forkPreset (copy-on-write lineage)', () => { + it('produces a new id and records the parent', () => { + const fork = forkPreset(lumenA, { state: { count: { type: 'int', init: 100 } } }); + expect(fork.parent).toBe(presetId(lumenA)); + expect(fork.id).not.toBe(fork.parent); + expect((fork.spec.preset as { id: string; parent: string })).toEqual({ id: fork.id, parent: fork.parent }); + expect((fork.spec.state as { count: { init: number } }).count.init).toBe(100); + }); + it('the forked id is stable & content-addressed (re-fork → same id)', () => { + const a = forkPreset(lumenA, { state: { count: { type: 'int', init: 7 } } }); + const b = forkPreset(lumenA, { state: { count: { type: 'int', init: 7 } } }); + expect(a.id).toBe(b.id); + }); +}); diff --git a/middleware/packages/canvas-core/test/sharing.test.ts b/middleware/packages/canvas-core/test/sharing.test.ts new file mode 100644 index 00000000..f8e29fbe --- /dev/null +++ b/middleware/packages/canvas-core/test/sharing.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { stripTokensForShare, importShared, canvasOwnershipGroup } from '../src/capabilities/sharing.js'; + +// A Lumen carrying two assets via DataRefs with the AUTHOR's signed tokens. +const assetA = { id: 'pixel-1111111111111111', signedToken: 'AUTHOR-SECRET-A', expiresAt: '2999-01-01T00:00:00Z' }; +const assetB = { id: 'pixel-2222222222222222', signedToken: 'AUTHOR-SECRET-B', expiresAt: '2999-01-01T00:00:00Z' }; +const authored = { + type: 'lumen', + id: 'gallery', + state: { hero: { type: 'dataRef', init: assetA } }, + view: { record: { type: { lit: 'scene' }, draw: { list: [{ record: { kind: { lit: 'sprite' }, dataRef: { lit: assetB } } }] } } }, + capabilities: [{ cap: 'tiles' }, { cap: 'generateAsset' }], +}; + +describe('stripTokensForShare (§9 assets travel by id, not token)', () => { + it('removes every author signedToken/expiresAt, keeps the content id', () => { + const { shared, assetIds } = stripTokensForShare(authored); + const json = JSON.stringify(shared); + expect(json).not.toContain('AUTHOR-SECRET-A'); + expect(json).not.toContain('AUTHOR-SECRET-B'); + expect(json).toContain('pixel-1111111111111111'); + expect(assetIds.sort()).toEqual(['pixel-1111111111111111', 'pixel-2222222222222222']); + }); +}); + +describe('importShared (§9 recipient-scoped re-mint + inert)', () => { + const mint = (id: string, recipient: string) => ({ signedToken: `${recipient}:${id}`, expiresAt: '2999-01-01T00:00:00Z' }); + + it('re-mints recipient-scoped tokens for authorised assets', () => { + const { shared } = stripTokensForShare(authored); + const res = importShared(shared, 'bob', { manifest: authored.capabilities, authorize: () => true, mint }); + const json = JSON.stringify(res.lumen); + expect(json).toContain('bob:pixel-1111111111111111'); + expect(res.reminted.sort()).toEqual(['pixel-1111111111111111', 'pixel-2222222222222222']); + expect(res.inert).toEqual([]); + }); + + it('renders an un-authorised asset inert (no borrowed token)', () => { + const { shared } = stripTokensForShare(authored); + const res = importShared(shared, 'bob', { + manifest: authored.capabilities, + authorize: (id) => id === 'pixel-1111111111111111', // bob may not access asset B + mint, + }); + expect(res.reminted).toEqual(['pixel-1111111111111111']); + expect(res.inert).toEqual(['pixel-2222222222222222']); + const json = JSON.stringify(res.lumen); + expect(json).toContain('"inert":true'); // B marked inert + expect(json).not.toContain('bob:pixel-2222222222222222'); // never minted for B + }); + + it('never trusts an inbound token even if one was smuggled in', () => { + const smuggled = { type: 'lumen', id: 'x', view: { record: { dataRef: { lit: { id: 'pixel-3333333333333333', signedToken: 'STOLEN' } } } } }; + const res = importShared(smuggled, 'bob', { authorize: () => true, mint }); + expect(JSON.stringify(res.lumen)).not.toContain('STOLEN'); + expect(JSON.stringify(res.lumen)).toContain('bob:pixel-3333333333333333'); + }); + + it('reports consent + importability from the capability manifest', () => { + const { shared } = stripTokensForShare(authored); + const res = importShared(shared, 'bob', { manifest: [{ cap: 'tiles' }, { cap: 'clipboard' }], authorize: () => true, mint }); + expect(res.importable).toBe(true); + expect(res.consent.requiresConsent).toContain('clipboard'); + const bad = importShared(shared, 'bob', { manifest: [{ cap: 'exec' }], authorize: () => true, mint }); + expect(bad.importable).toBe(false); + }); +}); + +describe('canvasOwnershipGroup (§9)', () => { + it('builds a deduped member group', () => { + expect(canvasOwnershipGroup(['a', 'b', 'a'])).toEqual({ kind: 'group', members: ['a', 'b'] }); + }); +}); diff --git a/middleware/packages/canvas-core/tools/genValidator.ts b/middleware/packages/canvas-core/tools/genValidator.ts index 4f461d1c..c823a363 100644 --- a/middleware/packages/canvas-core/tools/genValidator.ts +++ b/middleware/packages/canvas-core/tools/genValidator.ts @@ -21,13 +21,21 @@ const load = (name: string): object => JSON.parse(readFileSync(join(schemaDir, `${name}.schema.json`), 'utf8')) as object; const ajv = new Ajv2020({ allErrors: true, strict: false, code: { source: true, esm: true } }); -for (const name of ['data-ref', 'target-ref', 'canvas-tree', 'handshake', 'sentinels', 'surface-events']) { +for (const name of [ + 'data-ref', 'target-ref', 'canvas-tree', 'handshake', 'sentinels', 'surface-events', + // omadia-canvas-protocol/1.1 — Lumens (Live Interactivity), additive (lumens-spec.md). + 'lx-ast', 'scene', 'ports-wires', 'capability-manifest', 'lumen', +]) { ajv.addSchema(load(name)); } let code = standaloneCode(ajv, { validateTree: 'https://omadia.ai/protocol/1.0/canvas-tree.schema.json', validateSurfaceEvent: 'https://omadia.ai/protocol/1.0/surface-events.schema.json', + // 1.1 — the Lumen whitelist parser (structural; L1 adds the semantic bounds check). + validateLumen: 'https://omadia.ai/protocol/1.1/lumen.schema.json', + validateScene: 'https://omadia.ai/protocol/1.1/scene.schema.json', + validateLxNode: 'https://omadia.ai/protocol/1.1/lx-ast.schema.json', }); // Ajv emits ESM exports but still pulls runtime helpers via require() — From 99bb8cdb66635f5935a387ba7116967e03869512 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 16 Jun 2026 07:50:33 +0200 Subject: [PATCH 2/6] feat(ui-orchestrator): Lumen producer tool + 1.1 tree validation (Tier-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes Lumens reachable end-to-end over a live server (the producer half of #34): - canvas_publish_lumen — a canvas-output producer that instantiates a vetted reference Lumen (variant: arcade game · interactive map · defrag animation) as a surface_snapshot. Authorised in the canvas-output allow-set AND the deterministic-action allow-set, so a canvas action of that type dispatches LLM-free (a click renders the Lumen with no model turn). - referenceLumens.ts — the three vetted reference Lumens (declarative LX data), each exercising a different slice: tick simulation + events, interactive selection/zoom via get/if, and a map(range) tick animation. - treeValidator: extended additively to omadia-canvas-protocol/1.1 — the 1.0 canvas-tree with scene + lumen added to the primitive oneOf, so a snapshot carrying a Lumen validates server-side. Depends on the canvas-core 1.1 schemas in this PR. --- .../omadia-ui-orchestrator/src/plugin.ts | 76 +++++++- .../src/referenceLumens.ts | 184 ++++++++++++++++++ .../src/treeValidator.ts | 27 ++- 3 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 middleware/packages/omadia-ui-orchestrator/src/referenceLumens.ts diff --git a/middleware/packages/omadia-ui-orchestrator/src/plugin.ts b/middleware/packages/omadia-ui-orchestrator/src/plugin.ts index caa4b1b5..c2b9c8f9 100644 --- a/middleware/packages/omadia-ui-orchestrator/src/plugin.ts +++ b/middleware/packages/omadia-ui-orchestrator/src/plugin.ts @@ -24,6 +24,7 @@ import { parseRefreshSource, } from './refreshRecipes.js'; import { synthesizeSurfaceEvents } from './surfaceSynthesis.js'; +import { resolveReferenceLumen } from './referenceLumens.js'; /** * @omadia/ui-orchestrator — Omadia UI Tier-2 orchestrator (PR-9b-2). @@ -319,6 +320,35 @@ export async function handleCanvasPublishChoice(input: unknown): Promise }); } +// omadia-canvas-protocol/1.1 — Lumens (Live Interactivity) producer tool. +export const CANVAS_LUMEN_TOOL = 'canvas_publish_lumen'; + +/** NativeToolHandler for {@link CANVAS_LUMEN_TOOL}: emits a full-tree + * `_pendingCanvasTree` snapshot whose content is a vetted reference Lumen + * (chosen by `variant`: arcade game · interactive map · defrag animation). The + * synthesis layer turns it into a `surface_snapshot`; the Tier-1 client + * validates and runs it (the interpreter ships in the host app). */ +export async function handleCanvasPublishLumen(input: unknown): Promise { + const args = (typeof input === 'object' && input !== null ? input : {}) as Record; + const ref = resolveReferenceLumen(args['variant']); + const title = + typeof args['title'] === 'string' && args['title'].trim().length > 0 ? args['title'].trim() : ref.title; + const tree = { + type: 'container', + id: 'lumen-demo', + layout: 'stack', + title, + children: [ + { type: 'heading', id: 'lumen-demo-h', level: 2, content: title }, + { type: 'text', id: 'lumen-demo-hint', content: ref.hint }, + ref.lumen, + ], + }; + // The sentinel shape parseToolEmittedCanvasTree expects is + // { _pendingCanvasTree: { tree } } — the tree wrapped under a `tree` key. + return JSON.stringify({ _pendingCanvasTree: { tree } }); +} + export async function activate( ctx: PluginContext, ): Promise { @@ -335,6 +365,7 @@ export async function activate( const configuredCanvasOutputTools: ReadonlySet = new Set([ CANVAS_PUBLISH_TOOL, CANVAS_CHOICE_TOOL, + CANVAS_LUMEN_TOOL, ...(ctx.config?.get('canvas_output_tools') ?? '') .split(',') .map((s) => s.trim()) @@ -364,12 +395,16 @@ export async function activate( // here still goes through the agent loop. Operator override via the // `deterministic_action_tools` config field; `deterministicActionRegistry` // is the forward-compat manifest-autodiscovery hook (absent today = no-op). - const configuredDeterministicActionTools: ReadonlySet = new Set( - (ctx.config?.get('deterministic_action_tools') ?? '') + const configuredDeterministicActionTools: ReadonlySet = new Set([ + // A Lumen publish is fully plugin-determined (no data/LLM needed) — when a + // canvas action names it, dispatch it DIRECTLY so a click renders the Lumen + // with no model turn (also sidesteps LLM tool-call reliability). + CANVAS_LUMEN_TOOL, + ...(ctx.config?.get('deterministic_action_tools') ?? '') .split(',') .map((s) => s.trim()) .filter((s) => s.length > 0), - ); + ]); const deterministicActionTools: { has(name: string): boolean } = { has: (name: string): boolean => configuredDeterministicActionTools.has(name) || @@ -554,8 +589,40 @@ export async function activate( }, handleCanvasPublishChoice, ); + const disposeLumenTool: (() => void) | undefined = toolsAccessor?.register( + { + name: CANVAS_LUMEN_TOOL, + description: + 'Render an interactive LUMEN (Live Interactivity, omadia-canvas-protocol/1.1) on the ' + + 'Omadia UI canvas: a small live mini-app — a game, an animated/interactive visualization, ' + + 'or a "live artifact" — that runs Tier-1-fast on the client (60fps, no per-frame server ' + + 'round-trip), as declarative data run by a shipped deterministic interpreter (no arbitrary ' + + 'code). Call this when the user asks to SEE or BUILD something interactive/animated/playable ' + + 'on the canvas, a mini-game, or "show me what Lumens / live artifacts can do". Pick the ' + + '`variant` that best fits the request: "arcade" (a playable bouncing-ball game — tick loop + ' + + 'tap/key events), "map" (an interactive map — tap markers to select, +/- to zoom), or ' + + '"defrag" (an animated data visualisation — blocks compacting each tick). The interactive ' + + 'element renders directly into the canvas; do not also describe it as a static table.', + input_schema: { + type: 'object', + properties: { + variant: { + type: 'string', + enum: ['arcade', 'map', 'defrag'], + description: + 'which reference Lumen to instantiate: arcade (game), map (interactive selection + zoom), defrag (tick animation). Defaults to arcade.', + }, + title: { + type: 'string', + description: 'optional heading shown above the Lumen, in the user’s language', + }, + }, + }, + }, + handleCanvasPublishLumen, + ); ctx.log( - `[omadia-ui-orchestrator] producer tools ${CANVAS_PUBLISH_TOOL}+${CANVAS_CHOICE_TOOL} ${ + `[omadia-ui-orchestrator] producer tools ${CANVAS_PUBLISH_TOOL}+${CANVAS_CHOICE_TOOL}+${CANVAS_LUMEN_TOOL} ${ disposeTool ? 'registered' : 'NOT registered (no tools accessor in this context)' }`, ); @@ -1103,6 +1170,7 @@ export async function activate( ctx.log('deactivating omadia-ui-orchestrator'); disposeTool?.(); disposeChoiceTool?.(); + disposeLumenTool?.(); dispose(); }, }; diff --git a/middleware/packages/omadia-ui-orchestrator/src/referenceLumens.ts b/middleware/packages/omadia-ui-orchestrator/src/referenceLumens.ts new file mode 100644 index 00000000..1f9f5a6e --- /dev/null +++ b/middleware/packages/omadia-ui-orchestrator/src/referenceLumens.ts @@ -0,0 +1,184 @@ +/** + * omadia-canvas-protocol/1.1 — vetted reference Lumens (Live Interactivity). + * + * Declarative DATA, not code: a deterministic, gas-bounded LX interpreter runs + * each one Tier-1-fast (60fps, no per-frame server round-trip). These are the + * "resolve-then-instantiate" presets the `canvas_publish_lumen` producer hands + * to the canvas — each exercises a different slice of the implementation: + * - arcade — a tick simulation + pointer/key events + a `scene` draw-list + * - map — interactive selection (tap → state → conditional highlight via + * `get`/`if`), `+`/`-` zoom (clamp), `map` over record markers + * - defrag — a tick-driven animation built by `map(range)` (compacting blocks) + */ +export interface ReferenceLumen { + title: string; + hint: string; + lumen: Record; +} + +const ARCADE: Record = { + type: 'lumen', + id: 'arcade-bounce', + state: { + x: { type: 'number', min: 0, max: 300, init: 20 }, + vx: { type: 'number', init: 6 }, + bounces: { type: 'int', min: 0, init: 0 }, + }, + transitions: { + step: { + let: { nx: { '+': [{ state: 'x' }, { state: 'vx' }] } }, + in: { + let: { hitWall: { or: [{ '<=': [{ var: 'nx' }, { lit: 0 }] }, { '>=': [{ var: 'nx' }, { lit: 300 }] }] } }, + in: { + set: { + x: { call: 'clamp', args: [{ var: 'nx' }, { lit: 0 }, { lit: 300 }] }, + vx: { if: { var: 'hitWall' }, then: { '-': [{ lit: 0 }, { state: 'vx' }] }, else: { state: 'vx' } }, + bounces: { if: { var: 'hitWall' }, then: { '+': [{ state: 'bounces' }, { lit: 1 }] }, else: { state: 'bounces' } }, + }, + }, + }, + }, + reverse: { set: { vx: { '-': [{ lit: 0 }, { state: 'vx' }] } } }, + }, + view: { + record: { + type: { lit: 'scene' }, + width: { lit: 320 }, + height: { lit: 120 }, + draw: { + list: [ + { record: { kind: { lit: 'rect' }, x: { lit: 0 }, y: { lit: 0 }, w: { lit: 320 }, h: { lit: 120 }, fill: { lit: 'surface-sunken' } } }, + { record: { kind: { lit: 'circle' }, cx: { state: 'x' }, cy: { lit: 60 }, r: { lit: 10 }, fill: { lit: 'accent' }, id: { lit: 'ball' } } }, + { record: { kind: { lit: 'text' }, x: { lit: 8 }, y: { lit: 16 }, text: { call: 'concat', args: [{ lit: 'bounces ' }, { call: 'fmt', args: [{ state: 'bounces' }] }] }, register: { lit: 'mono' }, fill: { lit: 'text' } } }, + ], + }, + }, + }, + events: [ + { on: 'tick', rate: 60, run: 'step' }, + { on: 'tap', run: 'reverse' }, + { on: 'key', key: 'Space', run: 'reverse' }, + ], + cadence: { tick: 60 }, +}; + +const MAP: Record = { + type: 'lumen', + id: 'map-explore', + state: { + zoom: { type: 'int', min: 1, max: 5, init: 2 }, + sel: { type: 'string', maxLength: 32, init: '' }, + markers: { + type: 'list', + of: { type: 'record', fields: { id: { type: 'string', maxLength: 8, init: '' }, x: { type: 'number', init: 0 }, y: { type: 'number', init: 0 } }, init: {} }, + maxLen: 32, + init: [ + { id: 'a', x: 40, y: 40 }, + { id: 'b', x: 120, y: 80 }, + { id: 'c', x: 200, y: 50 }, + ], + }, + }, + transitions: { + select: { set: { sel: { event: 'id' } } }, + zoomIn: { set: { zoom: { call: 'clamp', args: [{ '+': [{ state: 'zoom' }, { lit: 1 }] }, { lit: 1 }, { lit: 5 }] } } }, + zoomOut: { set: { zoom: { call: 'clamp', args: [{ '-': [{ state: 'zoom' }, { lit: 1 }] }, { lit: 1 }, { lit: 5 }] } } }, + }, + view: { + record: { + type: { lit: 'scene' }, + width: { lit: 256 }, + height: { lit: 128 }, + draw: { + call: 'concat', + args: [ + { list: [{ record: { kind: { lit: 'rect' }, x: { lit: 0 }, y: { lit: 0 }, w: { lit: 256 }, h: { lit: 128 }, fill: { lit: 'surface-sunken' } } }] }, + { + call: 'map', + args: [ + { state: 'markers' }, + { + record: { + kind: { lit: 'circle' }, + cx: { get: { var: 'it' }, key: { lit: 'x' } }, + cy: { get: { var: 'it' }, key: { lit: 'y' } }, + r: { '+': [{ lit: 6 }, { state: 'zoom' }] }, + fill: { if: { '==': [{ get: { var: 'it' }, key: { lit: 'id' } }, { state: 'sel' }] }, then: { lit: 'success' }, else: { lit: 'accent' } }, + id: { get: { var: 'it' }, key: { lit: 'id' } }, + }, + }, + ], + }, + ], + }, + }, + }, + events: [ + { on: 'tap', run: 'select' }, + { on: 'key', key: '+', run: 'zoomIn' }, + { on: 'key', key: '-', run: 'zoomOut' }, + ], + cadence: 'reactive', + capabilities: [{ cap: 'tiles', effect: 'internal', scope: { provider: 'osm' } }], +}; + +const DEFRAG: Record = { + type: 'lumen', + id: 'defrag-viz', + state: { frame: { type: 'int', min: 0, init: 0 } }, + transitions: { step: { set: { frame: { '+': [{ state: 'frame' }, { lit: 1 }] } } } }, + view: { + record: { + type: { lit: 'scene' }, + width: { lit: 256 }, + height: { lit: 64 }, + draw: { + call: 'map', + args: [ + { call: 'range', args: [{ lit: 8 }] }, + { + record: { + kind: { lit: 'rect' }, + x: { call: 'max', args: [{ '*': [{ var: 'idx' }, { lit: 30 }] }, { '-': [{ lit: 220 }, { '*': [{ state: 'frame' }, { lit: 5 }] }] }] }, + y: { lit: 20 }, + w: { lit: 24 }, + h: { lit: 24 }, + r: { lit: 4 }, + fill: { if: { '==': [{ mod: [{ var: 'idx' }, { lit: 2 }] }, { lit: 0 }] }, then: { lit: 'accent' }, else: { lit: 'accent.glow' } }, + id: { call: 'concat', args: [{ lit: 'b' }, { call: 'fmt', args: [{ var: 'idx' }] }] }, + }, + }, + ], + }, + }, + }, + events: [ + { on: 'tick', rate: 30, run: 'step' }, + { on: 'tap', run: 'step' }, + ], + cadence: { tick: 30 }, +}; + +export type ReferenceLumenVariant = 'arcade' | 'map' | 'defrag'; + +export const REFERENCE_LUMENS: Record = { + arcade: { + title: 'Live Interactivity — Arcade Lumen', + hint: 'Tap the canvas (or press Space) to reverse the ball — it bounces off the walls at 60fps and counts, all interpreted locally with no per-frame server round-trip.', + lumen: ARCADE, + }, + map: { + title: 'Live Interactivity — Interactive Map Lumen', + hint: 'Tap a marker to select it (it highlights via a state-driven conditional); press + / - to zoom the markers. Pure Tier-1 interaction — selection and zoom are deterministic state transitions.', + lumen: MAP, + }, + defrag: { + title: 'Live Interactivity — Defrag Visualisation Lumen', + hint: 'A tick-driven animation: eight blocks compact leftward at 30fps, each frame recomputed by an LX map over a bounded range. Tap to step it manually.', + lumen: DEFRAG, + }, +}; + +export function resolveReferenceLumen(variant: unknown): ReferenceLumen { + return REFERENCE_LUMENS[variant === 'map' || variant === 'defrag' ? variant : 'arcade']; +} diff --git a/middleware/packages/omadia-ui-orchestrator/src/treeValidator.ts b/middleware/packages/omadia-ui-orchestrator/src/treeValidator.ts index 65610834..f89644b1 100644 --- a/middleware/packages/omadia-ui-orchestrator/src/treeValidator.ts +++ b/middleware/packages/omadia-ui-orchestrator/src/treeValidator.ts @@ -30,6 +30,12 @@ for (const file of [ 'handshake.schema.json', 'sentinels.schema.json', 'surface-events.schema.json', + // omadia-canvas-protocol/1.1 — Lumens (Live Interactivity), additive. + 'lx-ast.schema.json', + 'scene.schema.json', + 'ports-wires.schema.json', + 'capability-manifest.schema.json', + 'lumen.schema.json', ]) { ajv.addSchema(loadSchema(file)); } @@ -42,7 +48,26 @@ function mustGetSchema(id: string): ValidateFunction { return validate as ValidateFunction; } -const treeValidate = mustGetSchema('https://omadia.ai/protocol/1.0/canvas-tree.schema.json'); +// omadia-canvas-protocol/1.1 tree validator: the 1.0 canvas-tree with `scene` +// and `lumen` added to the `primitive` oneOf. Additive — every 1.0 tree still +// validates; 1.1 nodes are now accepted wherever a primitive is allowed. Built +// by cloning the 1.0 schema so the canonical file stays untouched. +const tree11Id = (() => { + const base = loadSchema('canvas-tree.schema.json') as { + $id: string; + $defs: { primitive: { oneOf: { $ref: string }[] } }; + }; + const clone = JSON.parse(JSON.stringify(base)) as typeof base; + clone.$id = 'https://omadia.ai/protocol/1.1/canvas-tree.schema.json'; + clone.$defs.primitive.oneOf.push( + { $ref: 'https://omadia.ai/protocol/1.1/scene.schema.json' }, + { $ref: 'https://omadia.ai/protocol/1.1/lumen.schema.json' }, + ); + ajv.addSchema(clone); + return clone.$id; +})(); + +const treeValidate = mustGetSchema(tree11Id); export interface TreeValidationResult { ok: boolean; From 702f7adf9919f2226ec6d75a39ac0ee072ba425a Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 16 Jun 2026 07:58:28 +0200 Subject: [PATCH 3/6] feat(ui-orchestrator): agent-authored Lumens (dynamic LLM generation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canvas_publish_lumen now PREFERS an agent-authored `lumen` (the real thesis: the LLM generates declarative LX data; the host validates it). The tool validates the authored Lumen structurally (validateLumenNode against the 1.1 lumen schema) and, on failure, returns a path-pointed error so the agent self-corrects — this is what makes LLM-generated interactivity safe (the model proposes data, the host proves it bounded/total/deterministic before it runs). The tool description carries the compact LX grammar + a worked example. The `variant` reference presets remain as a canned-demo fallback (resolve-then- generate). Semantic bounds beyond the schema are enforced Tier-1 by the interpreter (halts a bad Lumen with surface_error, never the canvas). --- .../omadia-ui-orchestrator/src/plugin.ts | 87 ++++++++++++++----- .../src/treeValidator.ts | 19 ++++ 2 files changed, 85 insertions(+), 21 deletions(-) diff --git a/middleware/packages/omadia-ui-orchestrator/src/plugin.ts b/middleware/packages/omadia-ui-orchestrator/src/plugin.ts index c2b9c8f9..661a3b4a 100644 --- a/middleware/packages/omadia-ui-orchestrator/src/plugin.ts +++ b/middleware/packages/omadia-ui-orchestrator/src/plugin.ts @@ -25,6 +25,7 @@ import { } from './refreshRecipes.js'; import { synthesizeSurfaceEvents } from './surfaceSynthesis.js'; import { resolveReferenceLumen } from './referenceLumens.js'; +import { validateLumenNode } from './treeValidator.js'; /** * @omadia/ui-orchestrator — Omadia UI Tier-2 orchestrator (PR-9b-2). @@ -323,16 +324,45 @@ export async function handleCanvasPublishChoice(input: unknown): Promise // omadia-canvas-protocol/1.1 — Lumens (Live Interactivity) producer tool. export const CANVAS_LUMEN_TOOL = 'canvas_publish_lumen'; -/** NativeToolHandler for {@link CANVAS_LUMEN_TOOL}: emits a full-tree - * `_pendingCanvasTree` snapshot whose content is a vetted reference Lumen - * (chosen by `variant`: arcade game · interactive map · defrag animation). The - * synthesis layer turns it into a `surface_snapshot`; the Tier-1 client - * validates and runs it (the interpreter ships in the host app). */ +/** NativeToolHandler for {@link CANVAS_LUMEN_TOOL}. Two paths: + * - AUTHORED (preferred): the agent passes a full `lumen` it wrote FOR THIS + * request. It is hard-validated (validateLumenFull = structural whitelist + + * semantic bounds); an invalid Lumen is rejected with an actionable error so + * the agent self-corrects, and never partially renders. This is precisely + * what makes LLM-generated interactivity SAFE: the model proposes declarative + * data, the host PROVES it bounded/total/deterministic before it ever runs. + * - PRESET (fallback): no `lumen` → instantiate a vetted reference by `variant`. + * The valid Lumen is emitted as a full-tree `_pendingCanvasTree` snapshot; the + * synthesis layer turns it into a `surface_snapshot`. */ export async function handleCanvasPublishLumen(input: unknown): Promise { const args = (typeof input === 'object' && input !== null ? input : {}) as Record; - const ref = resolveReferenceLumen(args['variant']); - const title = - typeof args['title'] === 'string' && args['title'].trim().length > 0 ? args['title'].trim() : ref.title; + const authored = args['lumen']; + let lumen: Record; + let title: string; + let hint: string | undefined; + if (authored && typeof authored === 'object' && !Array.isArray(authored)) { + const verdict = validateLumenNode(authored); + if (!verdict.ok) { + return ( + `Error: the authored Lumen is invalid and was NOT published — ${verdict.errors ?? 'unknown error'}. ` + + 'A Lumen is declarative DATA: { type:"lumen", id, state, transitions, view, events }. ' + + 'state leaves are typed + bounded; transitions are pure LX returning a new state via ' + + '{ set:{ path: expr } }; view is an LX expression returning a primitive/scene tree; every ' + + 'event.run must name a declared transition. Fix the reported issue and call the tool again.' + ); + } + lumen = authored as Record; + title = + typeof args['title'] === 'string' && args['title'].trim().length > 0 + ? args['title'].trim() + : 'Live Interactivity — Lumen'; + } else { + const ref = resolveReferenceLumen(args['variant']); + lumen = ref.lumen; + title = + typeof args['title'] === 'string' && args['title'].trim().length > 0 ? args['title'].trim() : ref.title; + hint = ref.hint; + } const tree = { type: 'container', id: 'lumen-demo', @@ -340,8 +370,8 @@ export async function handleCanvasPublishLumen(input: unknown): Promise title, children: [ { type: 'heading', id: 'lumen-demo-h', level: 2, content: title }, - { type: 'text', id: 'lumen-demo-hint', content: ref.hint }, - ref.lumen, + ...(hint ? [{ type: 'text', id: 'lumen-demo-hint', content: hint }] : []), + lumen, ], }; // The sentinel shape parseToolEmittedCanvasTree expects is @@ -593,24 +623,39 @@ export async function activate( { name: CANVAS_LUMEN_TOOL, description: - 'Render an interactive LUMEN (Live Interactivity, omadia-canvas-protocol/1.1) on the ' + - 'Omadia UI canvas: a small live mini-app — a game, an animated/interactive visualization, ' + - 'or a "live artifact" — that runs Tier-1-fast on the client (60fps, no per-frame server ' + - 'round-trip), as declarative data run by a shipped deterministic interpreter (no arbitrary ' + - 'code). Call this when the user asks to SEE or BUILD something interactive/animated/playable ' + - 'on the canvas, a mini-game, or "show me what Lumens / live artifacts can do". Pick the ' + - '`variant` that best fits the request: "arcade" (a playable bouncing-ball game — tick loop + ' + - 'tap/key events), "map" (an interactive map — tap markers to select, +/- to zoom), or ' + - '"defrag" (an animated data visualisation — blocks compacting each tick). The interactive ' + - 'element renders directly into the canvas; do not also describe it as a static table.', + 'Render an interactive LUMEN (Live Interactivity, omadia-canvas-protocol/1.1) on the canvas: ' + + 'a small live mini-app — a game, an animated/interactive visualization, a tool — that runs ' + + 'Tier-1-fast on the client (up to 60fps, no per-frame server round-trip) as DECLARATIVE DATA ' + + 'run by a shipped deterministic interpreter (no arbitrary code). PREFER authoring a custom ' + + '`lumen` tailored to the user’s request (see its schema for the grammar + example); the host ' + + 'validates it (whitelist + bounds + determinism) and returns an error for you to fix if it is ' + + 'malformed, so author freely. Use `variant` only for a quick canned demo when the user just ' + + 'wants to see an example. The element renders directly into the canvas — do not also describe ' + + 'it as a static table.', input_schema: { type: 'object', properties: { + lumen: { + type: 'object', + description: + 'A complete Lumen you AUTHOR for the request (preferred). Shape: { "type":"lumen","id":string, ' + + '"state":{:}, "transitions":{:}, "view":, "events":[], "cadence"?:"reactive"|{"tick":<=60} }. ' + + 'LEAF: {"type":"int"|"number",min?,max?,"init":n} | {"type":"bool","init":b} | {"type":"string","maxLength":k,"init":s} | {"type":"enum","values":[..],"init":s} | {"type":"list","of":,"maxLen":k,"init":[]} | {"type":"grid","w":W,"h":H,"of":} | {"type":"record","fields":{..},"init":{}}. ' + + 'A transition returns a NEW state via {"set":{"":}}. BINDING: {"on":"tap"|"key"|"tick"|"longPress"|"drag"|"swipe","key"?:s,"rate"?:<=60,"run":""}. ' + + 'LX NODES: {"lit":v} {"state":"path"} {"event":"field"} {"var":"name"} {"let":{"n":expr},"in":expr} ; ' + + 'arithmetic {"+":[a,b]} "-" "*" "/" "mod" ; compare {">":[a,b]} ">=" "<" "<=" "==" "!=" ; {"and":[..]} {"or":[..]} {"not":x} ; ' + + '{"if":c,"then":a,"else":b} ; {"match":x,"cases":[{"when":w,"then":t}],"else":e} ; {"record":{k:expr}} {"list":[expr]} {"get":expr,"key":expr} ; ' + + '{"call":name,"args":[..]} name∈ map,filter,fold,range,len,min,max,clamp,abs,floor,ceil,round,sqrt,sign,pow,concat,slice,contains,indexOf,keys,values,upper,lower,pad,fmt,random,now (map/filter body reads {"var":"it"}/{"var":"idx"}; fold also {"var":"acc"}). NO loops/recursion/eval. ' + + 'VIEW returns a primitive/scene tree. A scene: {"record":{"type":{"lit":"scene"},"width":{"lit":W},"height":{"lit":H},"draw":{"list":[...]}}}, ' + + 'node {"record":{"kind":{"lit":"rect"},"x":..,"y":..,"w":..,"h":..,"fill"?:,"id"?:}} | "circle"(cx,cy,r) | "line"(x1,y1,x2,y2,stroke) | "text"(x,y,text) | "path"(points) | "group"(children). ' + + 'Colours are THEME TOKENS only: accent, accent.glow, surface, surface-raised, surface-sunken, text, text-muted, success, warning, danger. ' + + 'EXAMPLE (tap counter): {"type":"lumen","id":"counter","state":{"n":{"type":"int","min":0,"init":0}},"transitions":{"inc":{"set":{"n":{"+":[{"state":"n"},{"lit":1}]}}}},"view":{"record":{"type":{"lit":"scene"},"width":{"lit":220},"height":{"lit":80},"draw":{"list":[{"record":{"kind":{"lit":"text"},"x":{"lit":14},"y":{"lit":46},"text":{"call":"concat","args":[{"lit":"taps "},{"call":"fmt","args":[{"state":"n"}]}]},"fill":{"lit":"text"},"id":{"lit":"label"}}}]}}},"events":[{"on":"tap","run":"inc"}]}', + }, variant: { type: 'string', enum: ['arcade', 'map', 'defrag'], description: - 'which reference Lumen to instantiate: arcade (game), map (interactive selection + zoom), defrag (tick animation). Defaults to arcade.', + 'Quick canned demo if you are NOT authoring a custom `lumen`: arcade (game), map (interactive selection + zoom), defrag (tick animation).', }, title: { type: 'string', diff --git a/middleware/packages/omadia-ui-orchestrator/src/treeValidator.ts b/middleware/packages/omadia-ui-orchestrator/src/treeValidator.ts index f89644b1..fb47e658 100644 --- a/middleware/packages/omadia-ui-orchestrator/src/treeValidator.ts +++ b/middleware/packages/omadia-ui-orchestrator/src/treeValidator.ts @@ -69,6 +69,14 @@ const tree11Id = (() => { const treeValidate = mustGetSchema(tree11Id); +// Structural whitelist validator for a single authored Lumen (the agent- +// generated case): the omadia-canvas-protocol/1.1 lumen schema. This is the +// safety net that makes LLM-authored Lumens publishable — malformed state / LX +// / events are rejected with a path-pointed error the agent can fix. (Semantic +// bounds — transition/path/var coherence — are additionally enforced Tier-1 by +// the shipped interpreter, which halts a bad Lumen with surface_error.) +const lumenValidate = mustGetSchema('https://omadia.ai/protocol/1.1/lumen.schema.json'); + export interface TreeValidationResult { ok: boolean; /** human-readable Ajv error summary; null when ok */ @@ -85,3 +93,14 @@ export function validateTree(tree: unknown): TreeValidationResult { : (treeValidate.errors ?? []).map((e) => `${e.instancePath} ${e.message}`).join('; '), }; } + +/** Structural whitelist parser for a single authored Lumen (§1.1). */ +export function validateLumenNode(lumen: unknown): TreeValidationResult { + const ok = lumenValidate(lumen) as boolean; + return { + ok, + errors: ok + ? null + : (lumenValidate.errors ?? []).map((e) => `${e.instancePath} ${e.message}`).join('; '), + }; +} From 0e34859acdaf92102edf032e912f6d194d866119 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 16 Jun 2026 08:15:10 +0200 Subject: [PATCH 4/6] feat(ui-orchestrator): data-bound Lumens from real privacy-shielded datasets (L5 loadData) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canvas_publish_lumen now also takes a privacy-shield `datasetId` (+ optional labelField/valueField). The real rows are resolved SERVER-SIDE via the same privacy provider canvas_publish_rows uses — the unmasked data never reaches the LLM — and an interactive, tappable data Lumen is built deterministically in datasetLumen.ts (one selectable row per record; tap highlights via a state-driven conditional). This is the privacy-safe answer to 'visualise the user's real data as a live artifact': the model only passes the dataset handle, the host resolves + constructs. Locally verified (validateLumen ok; view + tap transition evaluate correctly). --- .../src/datasetLumen.ts | 101 ++++++++++++++++++ .../omadia-ui-orchestrator/src/plugin.ts | 97 +++++++++++++---- 2 files changed, 175 insertions(+), 23 deletions(-) create mode 100644 middleware/packages/omadia-ui-orchestrator/src/datasetLumen.ts diff --git a/middleware/packages/omadia-ui-orchestrator/src/datasetLumen.ts b/middleware/packages/omadia-ui-orchestrator/src/datasetLumen.ts new file mode 100644 index 00000000..1fedb391 --- /dev/null +++ b/middleware/packages/omadia-ui-orchestrator/src/datasetLumen.ts @@ -0,0 +1,101 @@ +/** + * omadia-canvas-protocol/1.1 — build a data-bound Lumen from REAL rows. + * + * The L5 "loadData" pattern, privacy-safe: with the Privacy Shield active the + * agent only ever sees MASKED values, so it cannot bake real data into LX. The + * producer instead resolves a privacy-shield `datasetId` SERVER-SIDE (the real + * rows never pass through the LLM) and constructs the Lumen here, deterministically. + * + * The result is an interactive list-chart: one tappable row per record (label + + * value), tapping a row highlights it (state-driven selection). All declarative + * LX — the shipped interpreter runs it Tier-1 with no per-frame round-trip. + */ +type Row = Record; + +const MAX_ROWS = 20; +const ROW_H = 24; +const TOP = 28; + +/** Pick a sensible label/value field when the agent didn't name one. */ +function firstStringField(rows: Row[]): string | undefined { + const r = rows[0] ?? {}; + return Object.keys(r).find((k) => typeof r[k] === 'string'); +} +function firstNumberField(rows: Row[]): string | undefined { + const r = rows[0] ?? {}; + return Object.keys(r).find((k) => typeof r[k] === 'number' || (typeof r[k] === 'string' && r[k] !== '' && !Number.isNaN(Number(r[k])))); +} + +export interface DatasetLumenOptions { + labelField?: string; + valueField?: string; +} + +/** Construct a tappable list-chart Lumen from resolved rows. */ +export function buildDatasetLumen(rows: Row[], opts: DatasetLumenOptions = {}): Record { + const labelField = + opts.labelField && rows.some((r) => opts.labelField! in r) ? opts.labelField : firstStringField(rows); + const valueField = + opts.valueField && rows.some((r) => opts.valueField! in r) ? opts.valueField : firstNumberField(rows); + + const data = rows.slice(0, MAX_ROWS).map((r) => ({ + label: labelField ? String(r[labelField] ?? '—').slice(0, 48) : '—', + value: valueField !== undefined ? Number(r[valueField]) || 0 : 0, + })); + const height = TOP + Math.max(1, data.length) * ROW_H + 12; + + // one tappable text row per record; highlighted when its index == sel. + const rowExpr = { + record: { + kind: { lit: 'text' }, + x: { lit: 14 }, + y: { '+': [{ lit: TOP }, { '*': [{ var: 'idx' }, { lit: ROW_H }] }] }, + text: { + call: 'concat', + args: [ + { get: { var: 'it' }, key: { lit: 'label' } }, + { lit: ' · ' }, + { call: 'fmt', args: [{ get: { var: 'it' }, key: { lit: 'value' } }] }, + ], + }, + register: { lit: 'mono' }, + fill: { + if: { '==': [{ call: 'fmt', args: [{ var: 'idx' }] }, { state: 'sel' }] }, + then: { lit: 'success' }, + else: { lit: 'text' }, + }, + id: { call: 'fmt', args: [{ var: 'idx' }] }, + }, + }; + + return { + type: 'lumen', + id: 'dataset-lumen', + state: { + rows: { + type: 'list', + of: { type: 'record', fields: { label: { type: 'string', maxLength: 64, init: '' }, value: { type: 'number', init: 0 } }, init: {} }, + maxLen: 64, + init: data, + }, + sel: { type: 'string', maxLength: 8, init: '' }, + }, + transitions: { select: { set: { sel: { event: 'id' } } } }, + view: { + record: { + type: { lit: 'scene' }, + width: { lit: 360 }, + height: { lit: height }, + draw: { + call: 'concat', + args: [ + { list: [{ record: { kind: { lit: 'rect' }, x: { lit: 0 }, y: { lit: 0 }, w: { lit: 360 }, h: { lit: height }, fill: { lit: 'surface-sunken' } } }] }, + { call: 'map', args: [{ state: 'rows' }, rowExpr] }, + ], + }, + }, + }, + events: [{ on: 'tap', run: 'select' }], + cadence: 'reactive', + }; +} diff --git a/middleware/packages/omadia-ui-orchestrator/src/plugin.ts b/middleware/packages/omadia-ui-orchestrator/src/plugin.ts index 661a3b4a..c284311e 100644 --- a/middleware/packages/omadia-ui-orchestrator/src/plugin.ts +++ b/middleware/packages/omadia-ui-orchestrator/src/plugin.ts @@ -26,6 +26,7 @@ import { import { synthesizeSurfaceEvents } from './surfaceSynthesis.js'; import { resolveReferenceLumen } from './referenceLumens.js'; import { validateLumenNode } from './treeValidator.js'; +import { buildDatasetLumen } from './datasetLumen.js'; /** * @omadia/ui-orchestrator — Omadia UI Tier-2 orchestrator (PR-9b-2). @@ -324,23 +325,52 @@ export async function handleCanvasPublishChoice(input: unknown): Promise // omadia-canvas-protocol/1.1 — Lumens (Live Interactivity) producer tool. export const CANVAS_LUMEN_TOOL = 'canvas_publish_lumen'; -/** NativeToolHandler for {@link CANVAS_LUMEN_TOOL}. Two paths: - * - AUTHORED (preferred): the agent passes a full `lumen` it wrote FOR THIS - * request. It is hard-validated (validateLumenFull = structural whitelist + - * semantic bounds); an invalid Lumen is rejected with an actionable error so - * the agent self-corrects, and never partially renders. This is precisely +/** NativeToolHandler for {@link CANVAS_LUMEN_TOOL}. Three paths, in order: + * - DATA-BOUND: the agent passes a privacy-shield `datasetId` (from a data tool + * THIS turn). The real rows resolve SERVER-SIDE (never through the LLM — so + * shielded data stays masked to the model) and an interactive data Lumen is + * built deterministically here. This is the L5 "loadData" pattern. + * - AUTHORED: the agent passes a full `lumen` it wrote → hard-validated + * (validateLumenNode); invalid → actionable error so it self-corrects. This is * what makes LLM-generated interactivity SAFE: the model proposes declarative * data, the host PROVES it bounded/total/deterministic before it ever runs. - * - PRESET (fallback): no `lumen` → instantiate a vetted reference by `variant`. - * The valid Lumen is emitted as a full-tree `_pendingCanvasTree` snapshot; the - * synthesis layer turns it into a `surface_snapshot`. */ -export async function handleCanvasPublishLumen(input: unknown): Promise { + * - PRESET (fallback): neither → a vetted reference by `variant`. + * The valid Lumen is emitted as a full-tree `_pendingCanvasTree` snapshot. */ +export async function handleCanvasPublishLumen( + input: unknown, + resolveDataset?: CanvasDatasetResolver, +): Promise { const args = (typeof input === 'object' && input !== null ? input : {}) as Record; - const authored = args['lumen']; let lumen: Record; - let title: string; + let title = typeof args['title'] === 'string' && args['title'].trim().length > 0 ? args['title'].trim() : ''; let hint: string | undefined; - if (authored && typeof authored === 'object' && !Array.isArray(authored)) { + + const datasetId = + typeof args['datasetId'] === 'string' && args['datasetId'].trim().length > 0 ? args['datasetId'].trim() : undefined; + const authored = args['lumen']; + + if (datasetId !== undefined) { + const resolved = resolveDataset === undefined ? 'unavailable' : resolveDataset(datasetId); + if (resolved === 'unavailable') { + return ( + 'Error: dataset-bound Lumens are unavailable on this server (no privacy provider with dataset ' + + 'support active). Author a `lumen` directly instead.' + ); + } + if (resolved === undefined) { + return ( + `Error: unknown or expired datasetId "${datasetId}" — dataset ids are only valid within the turn ` + + 'that interned them; re-run the data tool and publish in the SAME turn.' + ); + } + const labelField = typeof args['labelField'] === 'string' ? args['labelField'] : undefined; + const valueField = typeof args['valueField'] === 'string' ? args['valueField'] : undefined; + lumen = buildDatasetLumen( + resolved.rows.map((r) => ({ ...r })), + { labelField, valueField }, + ); + if (title === '') title = 'Live Interactivity — Data Lumen'; + } else if (authored && typeof authored === 'object' && !Array.isArray(authored)) { const verdict = validateLumenNode(authored); if (!verdict.ok) { return ( @@ -352,15 +382,11 @@ export async function handleCanvasPublishLumen(input: unknown): Promise ); } lumen = authored as Record; - title = - typeof args['title'] === 'string' && args['title'].trim().length > 0 - ? args['title'].trim() - : 'Live Interactivity — Lumen'; + if (title === '') title = 'Live Interactivity — Lumen'; } else { const ref = resolveReferenceLumen(args['variant']); lumen = ref.lumen; - title = - typeof args['title'] === 'string' && args['title'].trim().length > 0 ? args['title'].trim() : ref.title; + if (title === '') title = ref.title; hint = ref.hint; } const tree = { @@ -629,9 +655,11 @@ export async function activate( 'run by a shipped deterministic interpreter (no arbitrary code). PREFER authoring a custom ' + '`lumen` tailored to the user’s request (see its schema for the grammar + example); the host ' + 'validates it (whitelist + bounds + determinism) and returns an error for you to fix if it is ' + - 'malformed, so author freely. Use `variant` only for a quick canned demo when the user just ' + - 'wants to see an example. The element renders directly into the canvas — do not also describe ' + - 'it as a static table.', + 'malformed, so author freely. To visualise the user’s REAL data (e.g. Dynamics records behind ' + + 'the Privacy Shield), pass a `datasetId` a data tool returned THIS turn instead of authoring — ' + + 'the server resolves the real rows and builds an interactive, tappable data Lumen (you never ' + + 'see the unmasked values). Use `variant` only for a quick canned demo. The element renders ' + + 'directly into the canvas — do not also describe it as a static table.', input_schema: { type: 'object', properties: { @@ -651,11 +679,24 @@ export async function activate( 'Colours are THEME TOKENS only: accent, accent.glow, surface, surface-raised, surface-sunken, text, text-muted, success, warning, danger. ' + 'EXAMPLE (tap counter): {"type":"lumen","id":"counter","state":{"n":{"type":"int","min":0,"init":0}},"transitions":{"inc":{"set":{"n":{"+":[{"state":"n"},{"lit":1}]}}}},"view":{"record":{"type":{"lit":"scene"},"width":{"lit":220},"height":{"lit":80},"draw":{"list":[{"record":{"kind":{"lit":"text"},"x":{"lit":14},"y":{"lit":46},"text":{"call":"concat","args":[{"lit":"taps "},{"call":"fmt","args":[{"state":"n"}]}]},"fill":{"lit":"text"},"id":{"lit":"label"}}}]}}},"events":[{"on":"tap","run":"inc"}]}', }, + datasetId: { + type: 'string', + description: + 'Build a Lumen from REAL privacy-shielded data: a dataset id (ds_…) a data tool (e.g. dynamics_describe / dynamics_fetchxml) returned THIS turn. The server resolves the real rows and builds an interactive tappable data Lumen — you never see the unmasked values. Use INSTEAD of `lumen` when the user wants their actual data visualised.', + }, + labelField: { + type: 'string', + description: 'with datasetId: which field is the row label (defaults to the first text field).', + }, + valueField: { + type: 'string', + description: 'with datasetId: which field is the numeric value (defaults to the first numeric field).', + }, variant: { type: 'string', enum: ['arcade', 'map', 'defrag'], description: - 'Quick canned demo if you are NOT authoring a custom `lumen`: arcade (game), map (interactive selection + zoom), defrag (tick animation).', + 'Quick canned demo if you are NOT authoring a custom `lumen` or binding a `datasetId`: arcade (game), map (interactive selection + zoom), defrag (tick animation).', }, title: { type: 'string', @@ -664,7 +705,17 @@ export async function activate( }, }, }, - handleCanvasPublishLumen, + (input) => + handleCanvasPublishLumen(input, (datasetId) => { + const turnId = turnContext.current()?.turnId; + const privacy = ( + ctx.services as PluginContext['services'] | undefined + )?.get(PRIVACY_REDACT_SERVICE_NAME); + if (turnId === undefined || privacy?.resolveDatasetForRender === undefined) { + return 'unavailable'; + } + return privacy.resolveDatasetForRender(turnId, datasetId); + }), ); ctx.log( `[omadia-ui-orchestrator] producer tools ${CANVAS_PUBLISH_TOOL}+${CANVAS_CHOICE_TOOL}+${CANVAS_LUMEN_TOOL} ${ From 3b18b59749d85f876f541c773b6ca3fdb9ecf156 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 16 Jun 2026 09:03:50 +0200 Subject: [PATCH 5/6] feat(ui-orchestrator): accept inline data rows for data-bound Lumens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `data` param to canvas_publish_lumen: when the agent already HOLDS the rows (an unshielded fetch — e.g. dynamics tools in bypass mode emit real rows directly, no datasetId interned), it passes them as `data` and the server builds the interactive data Lumen via buildDatasetLumen — no LX authoring needed. Complements `datasetId` (masked/shielded path, resolved server-side). Tool description now steers: visible rows → `data`, masked → `datasetId`. --- .../omadia-ui-orchestrator/src/plugin.ts | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/middleware/packages/omadia-ui-orchestrator/src/plugin.ts b/middleware/packages/omadia-ui-orchestrator/src/plugin.ts index c284311e..93c001da 100644 --- a/middleware/packages/omadia-ui-orchestrator/src/plugin.ts +++ b/middleware/packages/omadia-ui-orchestrator/src/plugin.ts @@ -347,9 +347,21 @@ export async function handleCanvasPublishLumen( const datasetId = typeof args['datasetId'] === 'string' && args['datasetId'].trim().length > 0 ? args['datasetId'].trim() : undefined; + const dataRows = Array.isArray(args['data']) + ? (args['data'] as unknown[]).filter( + (r): r is Record => typeof r === 'object' && r !== null && !Array.isArray(r), + ) + : undefined; const authored = args['lumen']; - if (datasetId !== undefined) { + if (datasetId === undefined && dataRows !== undefined && dataRows.length > 0) { + // Rows the agent already holds (e.g. an unshielded fetch) → build the data + // Lumen deterministically here, no LX-authoring required of the model. + const labelField = typeof args['labelField'] === 'string' ? args['labelField'] : undefined; + const valueField = typeof args['valueField'] === 'string' ? args['valueField'] : undefined; + lumen = buildDatasetLumen(dataRows, { labelField, valueField }); + if (title === '') title = 'Live Interactivity — Data Lumen'; + } else if (datasetId !== undefined) { const resolved = resolveDataset === undefined ? 'unavailable' : resolveDataset(datasetId); if (resolved === 'unavailable') { return ( @@ -655,11 +667,11 @@ export async function activate( 'run by a shipped deterministic interpreter (no arbitrary code). PREFER authoring a custom ' + '`lumen` tailored to the user’s request (see its schema for the grammar + example); the host ' + 'validates it (whitelist + bounds + determinism) and returns an error for you to fix if it is ' + - 'malformed, so author freely. To visualise the user’s REAL data (e.g. Dynamics records behind ' + - 'the Privacy Shield), pass a `datasetId` a data tool returned THIS turn instead of authoring — ' + - 'the server resolves the real rows and builds an interactive, tappable data Lumen (you never ' + - 'see the unmasked values). Use `variant` only for a quick canned demo. The element renders ' + - 'directly into the canvas — do not also describe it as a static table.', + 'malformed, so author freely. To visualise the user’s REAL data (e.g. Dynamics records): if you ' + + 'can SEE the rows you fetched, pass them as `data` (an array of row objects) — the server builds ' + + 'an interactive tappable data Lumen for you; if the values are MASKED/shielded, pass the ' + + '`datasetId` instead and the server resolves them. Use `variant` only for a quick canned demo. ' + + 'The element renders directly into the canvas — do not also describe it as a static table.', input_schema: { type: 'object', properties: { @@ -679,10 +691,16 @@ export async function activate( 'Colours are THEME TOKENS only: accent, accent.glow, surface, surface-raised, surface-sunken, text, text-muted, success, warning, danger. ' + 'EXAMPLE (tap counter): {"type":"lumen","id":"counter","state":{"n":{"type":"int","min":0,"init":0}},"transitions":{"inc":{"set":{"n":{"+":[{"state":"n"},{"lit":1}]}}}},"view":{"record":{"type":{"lit":"scene"},"width":{"lit":220},"height":{"lit":80},"draw":{"list":[{"record":{"kind":{"lit":"text"},"x":{"lit":14},"y":{"lit":46},"text":{"call":"concat","args":[{"lit":"taps "},{"call":"fmt","args":[{"state":"n"}]}]},"fill":{"lit":"text"},"id":{"lit":"label"}}}]}}},"events":[{"on":"tap","run":"inc"}]}', }, + data: { + type: 'array', + items: { type: 'object' }, + description: + 'Build a Lumen from data you ALREADY HOLD: an array of row objects you fetched this turn (e.g. Dynamics records you can see). The server builds an interactive, tappable data Lumen from them — no LX authoring needed. Prefer this for "visualise my data" requests when the rows are visible to you. AT MOST 20 rows are shown.', + }, datasetId: { type: 'string', description: - 'Build a Lumen from REAL privacy-shielded data: a dataset id (ds_…) a data tool (e.g. dynamics_describe / dynamics_fetchxml) returned THIS turn. The server resolves the real rows and builds an interactive tappable data Lumen — you never see the unmasked values. Use INSTEAD of `lumen` when the user wants their actual data visualised.', + 'Build a Lumen from REAL but PRIVACY-SHIELDED data: a dataset id (ds_…) a data tool returned THIS turn whose values are masked to you. The server resolves the real rows server-side and builds the Lumen — you never see the unmasked values. Use this (NOT `data`) only when the rows you got are masked/shielded.', }, labelField: { type: 'string', From 383d818edc52b41c9ce0705a8a555c69122327d2 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 16 Jun 2026 09:19:01 +0200 Subject: [PATCH 6/6] test(ui-orchestrator): include canvas_publish_lumen in producer-tool registration assertions The producer now registers three canvas-output tools (rows + choice + lumen); update the registration/dispose assertions accordingly (3 registered, 3 disposed). --- middleware/test/uiOrchestratorPlugin.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/middleware/test/uiOrchestratorPlugin.test.ts b/middleware/test/uiOrchestratorPlugin.test.ts index bb94f4bd..cda887c0 100644 --- a/middleware/test/uiOrchestratorPlugin.test.ts +++ b/middleware/test/uiOrchestratorPlugin.test.ts @@ -14,6 +14,7 @@ import { activate, CANVAS_CHAT_AGENT_SERVICE, CANVAS_CHOICE_TOOL, + CANVAS_LUMEN_TOOL, CANVAS_PUBLISH_TOOL, handleCanvasPublishChoice, handleCanvasPublishRows, @@ -363,9 +364,9 @@ describe('canvas_publish_choice producer tool', () => { }, } as unknown as PluginContext; const handle = await activate(ctx); - assert.deepEqual(registered, [CANVAS_PUBLISH_TOOL, CANVAS_CHOICE_TOOL]); + assert.deepEqual(registered, [CANVAS_PUBLISH_TOOL, CANVAS_CHOICE_TOOL, CANVAS_LUMEN_TOOL]); await handle.close(); - assert.equal(disposed, 2); + assert.equal(disposed, 3); }); });