diff --git a/docs/proposals/fiber-ergonomics/00-sdk-stdlib-and-templates.md b/docs/proposals/fiber-ergonomics/00-sdk-stdlib-and-templates.md new file mode 100644 index 00000000..8df7efb3 --- /dev/null +++ b/docs/proposals/fiber-ergonomics/00-sdk-stdlib-and-templates.md @@ -0,0 +1,619 @@ +# SDK Std-Lib + Templates — Executable Handoff (RFC) + +**Status:** executable handoff (for an agent working primarily in `~/repos/ottochain-sdk`, plus the +genesis side in `~/repos/ottochain`). **Origin:** friction authoring the `riverdale-economy` e2e +(`README.md` findings F1–F10). **Phases:** maps to the README roadmap P2 (SDK template library, off-chain, +low risk) and P3 (genesis std-lib, chain-touching, medium risk). + +This is the centerpiece deliverable of the fiber-ergonomics program: a self-contained spec another agent +can execute against. It does NOT implement anything — it is the plan. Read +`docs/proposals/fiber-ergonomics/README.md` first (the findings catalog + the P0→P4 roadmap), then this. + +Three moves: + +1. A **typed template/builder library** in `@ottochain/sdk` that emits canonical, correct forms for the + shapes app authors currently hand-roll as raw JSON (asset policies, versioned machines, effect + directives, migrations, seeded state shapes). +2. A **genesis-loaded canonical std-lib** of vetted registry packages (policy presets, machine skeletons) + so apps *reference* a blessed `std.*` package rather than redefine it. +3. **Apps consume templates** from the SDK instead of hand-rolling. The `riverdale-economy` example is the + migration proof. + +The hard guardrail throughout (CLAUDE.md rule #1): **nothing may shift the signed-message canonical.** A +template must emit byte-identical canonical to what the chain re-derives — `JCS(dropNulls(payload))` — or +the create/publish signature breaks with an opaque `InvalidSignature` / HTTP 400. Every builder is +additive: hand-rolled JSON keeps working. + +--- + +## 0. Today (baseline) + +### 0.1 The hand-rolled status quo + +A `riverdale-economy` app author writes, by hand, deeply-nested JSON-Logic across a dozen files. The +recurring shapes (all under `~/repos/ottochain-riverdale-e2e/e2e-test/examples/riverdale-economy/`): + +- **Asset policies** as raw JSON with a magic behavior bitmask and a free-form morphisms map — + `rvd-policy.json:1-19` (`"behavior": 28`, `supply.{mintPolicy,burnPolicy}`, `morphisms:{TRANSFER, + FRACTIONALIZE, BURN, STAKE}`), `goods-policy.json:1-13` (`"behavior": 20`, `morphisms:{}`), + `capped-policy.json:1-14` (`supply.maxSupply: 100`). The `28`/`20` are opaque T/S/C/E/G bit sums; a typo + is undetectable until mint time. +- **Versioned state-machine definitions** as raw `states`/`initialState`/`transitions` JSON — + `consumer.definition.json:1-174` (6 transitions, an inline 40-line `_spawn` child definition), + `retailer-v1.definition.json` / `retailer-v2.definition.json` (the v1→v2 verified-binding pair). +- **Effects** as `_`-reserved directive blobs interleaved with state-merge keys — + `consumer.definition.json:26-47` (`_triggers` + `_transferAsset` + `status`/`purchaseCount` in one + effect object); the `_transferAsset` recipient is `{ "var": "event.retailerId" }` resolving to a **bare + string** (`consumer.definition.json:38-43`). +- **Migrations** as `{"merge":[{"var":""},{…}]}` where the context root is the *bare* prior state — + `retailer-migration.json:1-3` (`{"merge":[{"var":""},{"loyaltyPoints":0}]}`), + `fed-migration.json:1-3`. +- **Seeded accumulators**: every counter (`purchaseCount`, `taxesPaid`, `loyaltyPoints`) must be pre-seeded + in `*.initial.json` or the `{"+":[{"var":"state.x"},…]}` reads `null` (F5). + +The harness consumes these by FILE NAME in a declarative step DSL — +`example.ts:124-142` (`createAssetPolicy`/`publishVersion`/`create` steps each name a `*.json`), `:193`, +`:208` (`upgradeFiber` names `newDefinition` + `migration` files). Errors surface only at ML0 combine. + +### 0.2 The exact SDK gap + +The SDK already ships a real, typed builder layer — this proposal *extends* it, it does not start from +zero. What EXISTS (`~/repos/ottochain-sdk/src/`): + +| Capability | Where | Status | +|---|---|---| +| Typed message envelopes (`createMintAssetPayload`, `createAssetPolicyPayload`, `createTransitionPayload`, …) | `ottochain/transaction.ts:70-295` | EXISTS — but thin: "the chain message types already model every field … these builders just apply the `{ MessageName: ... }` envelope" (`transaction.ts:266-271`). No domain construction. | +| Typed message/registry/asset types (`CreateAssetPolicy`, `MintAsset`, `SupplyPolicy`, `MorphismSpec`, `AssetHolder`, `SchemaRef`, `TOKEN_BEHAVIOR_BITS`) | `ottochain/types.ts:803-812, 692-716, 723-725, 131-135, 679-685` | EXISTS — types only, no constructors. | +| Typed machine builder `defineFiberApp` + `toProtoDefinition` (projects to the wire `StateMachineDefinition`, omit-on-unconstrained policy, strips authoring-only fields) | `schema/fiber-app.ts:554-561, 641-691` | EXISTS — but `defineFiberApp` is an identity passthrough ("Runtime validation could go here", `:559`). | +| Fiber-policy dial builders `constrained`/`unconstrained`/`immutable` | `schema/fiber-app.ts:379-461` | EXISTS. | +| Effect-directive builders `transferAsset`, `addDependency`, `setDependencyActive` | `schema/effects.ts:29-72` | EXISTS — but only THREE of the directives. | +| Authorization-guard builders (`signerIsParty`, `actorInSet`, `signerHasRole`, `depInState`, …) | `schema/guards.ts:23-285` | EXISTS. | +| Genesis manifest exporter `buildGenesisManifest` | `ottochain/genesis-manifest.ts:269-298` | EXISTS — but covers only 3 std MACHINE apps (identity/governance/markets); content-only (no hashes). | + +What is MISSING (the gap this handoff fills): + +1. **No asset-policy preset builders.** There is no `fungiblePolicy()`/`nftPolicy()`/`soulboundPolicy()`/ + `customPolicy()`. Authors compute `behavior: 28` by hand and assemble the `supply`/`morphisms` maps raw + (`rvd-policy.json`). `TOKEN_BEHAVIOR_BITS` exists (`types.ts:679-685`) but nothing consumes it. +2. **No effect-directive builders for `_triggers`, `_spawn`, `_emit`.** Only `transferAsset` / + `addDependency` / `setDependencyActive` exist (`effects.ts`). The two HEAVIEST riverdale shapes — + cross-fiber `triggers` (`consumer.definition.json:27-37`) and the inline `spawn` child + (`:101-153`) — are hand-rolled. +3. **No `migration()` helper.** The `{"merge":[{"var":""},{…}]}` bare-state-root idiom (F9, + `retailer-migration.json`) is written by hand and is a known foot-gun (root is `{"var":""}`, NOT + `state.x` like effects). +4. **No state-shape-with-defaults declaration** that auto-seeds accumulators (F5). Authors hand-maintain + `*.initial.json`. +5. **No versioned-machine skeleton** that ties `PublishMachineVersion` and `CreateStateMachine` to the + SAME definition for verified binding (F9) — the riverdale README warns the same definition file must be + reused byte-for-byte (`riverdale-economy/README.md:110-113`); nothing enforces it. +6. **No genesis std-lib of POLICY presets / machine skeletons.** `buildGenesisManifest` ships only machine + apps, and the chain manifest model carries only `machineShape` (`ottochain/.../GenesisManifest.scala:27-34`) + — there is no path to pre-register an asset-policy package at genesis at all. + +--- + +## 1. The template catalog + +The catalog is a new SDK subpath, `@ottochain/sdk/templates`, re-exporting (and extending) the existing +`schema/*` builders plus the new ones below. Each builder emits the EXACT wire shape and is named so call +sites read as intent. Every example below is a real riverdale form. + +### 1.1 Asset-policy presets — removes F10 (and the `behavior: 28` magic of F1's redesign) + +Emits the `CreateAssetPolicy` body (`types.ts:803-812`). On-chain the policy's `schemaHash` and +`logicHash` are both `RegistryShape.AssetPolicy.computeDigest` (no JSON-Logic body) — +`ottochain/.../AssetCombiner.scala:82-123` — so the presets are pure data and fully deterministic. + +Raw form it replaces (`rvd-policy.json:1-19`): + +```jsonc +{ "name": "rvd.asset", "version": "1.0.0", "behavior": 28, + "supply": { "mintPolicy": { "==": [1, 1] }, "burnPolicy": { "==": [1, 1] } }, + "morphisms": { "TRANSFER": { "visibility": "PUBLIC" }, "FRACTIONALIZE": { "visibility": "PUBLIC" }, + "BURN": { "visibility": "PUBLIC" }, "STAKE": { "visibility": "PUBLIC" } }, + "stateShape": { "typeName": "RvdState", "fields": [] } } +``` + +Proposed API (`src/templates/asset-policy.ts`): + +```ts +import type { CreateAssetPolicy, SupplyPolicy, MorphismSpec, MorphismKind } from '../ottochain/types.js'; + +/** Fungible currency: T|S|C = 28. TRANSFER + FRACTIONALIZE + BURN(opt) + STAKE morphisms, all PUBLIC. */ +export function fungiblePolicy(p: { + name: string; // must end `.asset` + version: string; // SemVer + decimals?: number; // supply.decimals + maxSupply?: number; // omit => uncapped + mintable?: boolean; // true => supply.mintPolicy = {"==":[1,1]} (or pass a guard) + burnable?: boolean; // true => supply.burnPolicy AND BURN morphism + mintGuard?: unknown; // JSON-Logic predicate, overrides mintable's default + stakeable?: boolean; // default true => STAKE morphism (codomain E:=1) + stateTypeName?: string; // stateShape.typeName, default `${PascalName}State` + metadata?: Record; +}): CreateAssetPolicy; + +/** Non-fungible: T = 16 (or T|C = 20 with `combinable: true`, the riverdale `goods.asset`). No morphisms by default. */ +export function nftPolicy(p: { + name: string; version: string; + combinable?: boolean; // 16 -> 20 + transferable?: boolean; // default true; false => 0/4 (a bound collectible) + metadata?: Record; +}): CreateAssetPolicy; + +/** Soulbound: non-transferable, governable only (G = 1). No TRANSFER morphism; mint closed after issue. */ +export function soulboundPolicy(p: { + name: string; version: string; expirable?: boolean; metadata?: Record; +}): CreateAssetPolicy; + +/** Escape hatch: declare behavior bits by NAME (never the magic int) + raw supply/morphisms. */ +export function customPolicy(p: { + name: string; version: string; + behavior: (keyof typeof TOKEN_BEHAVIOR_BITS)[]; // ['transferable','combinable'] -> 20 + supply: SupplyPolicy; + morphisms: Partial>; + stateTypeName?: string; metadata?: Record; +}): CreateAssetPolicy; +``` + +`behavior` is summed from `TOKEN_BEHAVIOR_BITS` (`types.ts:679-685`: T=16, S=8, C=4, E=2, G=1) — never a +literal. Presets: Fungible=28, NFT=16, goods-style NFT=20, soulbound=1. `morphisms` is REQUIRED on the wire +(presence required, emptiness meaningful — `Updates.scala:251-263`), so `nftPolicy` emits `morphisms: {}` +explicitly, never omits it. + +### 1.2 Versioned-machine skeleton + `transition()`/`guard()`/`effect()` — removes F9, F10 + +`defineFiberApp` (`fiber-app.ts:554`) already gives a typed machine. Add (a) a `transition()`/`guard()`/ +`effect()` composition layer so an effect is built from typed fragments rather than a JSON blob, and (b) a +`machine()` skeleton that binds publish + create to ONE definition for verified binding. + +```ts +// src/templates/machine.ts + +/** A typed transition. `effect()` composes state-merge fields + the `_`-directive fragments (§1.3). */ +export function transition(t: { + from: S; to: S; on: E; + guard?: GuardRule; // default {"==":[1,1]}; reuse schema/guards.ts builders + effect?: Record; // built by effect() + dependencies?: string[]; // bare UUID strings only (toProtoDefinition drops the rest) +}): Transition; + +/** Compose an effect: the state-update fields PLUS any `_`-directive fragments, in one map. + * `effect({ status: 'received' }, transferAsset([...]), triggers([...]))` spreads the directives in. */ +export function effect( + stateUpdate: Record, + ...directives: Record[] +): Record; + +/** Re-export the guard builders so `guard.signerIsParty('state.borrower')` reads at the call site. */ +export * as guard from '../schema/guards.js'; + +/** + * A versioned package skeleton. Holds ONE canonical FiberAppDefinition and emits BOTH: + * - publishVersion(): the PublishMachineVersion body (definition = toWireDefinition(def)) + * - create(opts): the CreateStateMachine body with schemaRef name@version + the SAME definition + * The definitions are byte-identical, so the chain's `definition.computeDigest` matches the registered + * `logicHash` and the verified bind admits the fiber (F9; Updates.scala:166-179, riverdale README:110-113). + */ +export function machine(spec: { + name: string; // `.package` + version: string; + app: FiberAppDefinition; + schemaShape: MachineShape; // the proto projection (advisory) +}): { + publishVersion(o?: { strict?: boolean; metadata?: Record }): PublishMachineVersion; + create(o: { fiberId: string; initialData: unknown; participants?: string[] }): CreateStateMachine; + upgradeFrom(o: { fiberId: string; targetSequenceNumber: number; migration?: unknown }): UpgradeFiber; + wireDefinition(): StateMachineDefinition; // for golden round-trip tests +}; +``` + +This directly encodes the F9 invariant: the skeleton OWNS the definition, so publish and create cannot +drift. `toWireDefinition`/`toProtoDefinition` already enforce the wire-parity rules (required +`dependencies: []`, omit-on-unconstrained `policy`, stripped authoring fields — `fiber-app.ts:641-691`, +`genesis-manifest.ts:227-255`); the skeleton reuses them. + +### 1.3 Effect-directive builders — removes F4 (typo'd `_trigger` silently becomes state) + +Complete the `effects.ts` set. Existing: `transferAsset`, `addDependency`, `setDependencyActive` +(`effects.ts:29-72`). Add `triggers`, `spawn`, `emit`. Each emits the EXACT reserved-key shape the chain's +`EffectExtractor` reads (`ottochain/.../EffectExtractor.scala`, keys in `ReservedKeys.scala:12-49`). All +return a `Record` so `effect()` (§1.2) spreads them into the effect map. + +**`transferAsset(transfers)`** — EXISTS (`effects.ts:70-72`). Emits `{ _transferAsset: [{ assetId, +recipient }] }`. Critical F2 detail it already gets right: `recipient` resolves to a **bare string**, NOT +an `AssetHolder` object — `EffectExtractor.parseRecipient` (`EffectExtractor.scala:242-246`) maps a +UUID-shaped string → `Fiber`, a DAG address → `Wallet` (UUID tried first). The catalog keeps this and adds +two typed convenience wrappers so authors stop reasoning about the disambiguation: + +```ts +export const toFiber = (fiberId: JsonLogicValue) => fiberId; // identity; documents intent +export const toWallet = (address: JsonLogicValue) => address; // identity; documents intent +// transferAsset([{ assetId, recipient: toFiber({ var: 'event.retailerId' }) }]) +``` + +Replaces `consumer.definition.json:38-43`. + +**`triggers(triggers)`** — NEW. Emits `{ _triggers: [{ targetMachineId, eventName, payload }] }` +(`ReservedKeys.scala:25-27`, `EffectExtractor.scala:104-141`). Replaces `consumer.definition.json:27-37`: + +```ts +export const triggers = ( + ts: { target: JsonLogicValue; event: string; payload?: Record }[], +): Record => ({ + _triggers: ts.map(t => ({ targetMachineId: t.target, eventName: t.event, payload: t.payload ?? {} })), +}); +``` + +**`spawn(directives)`** — NEW. Emits `{ _spawn: [{ childId, definition, initialData, owners }] }` +(`ReservedKeys.scala:35-38`, `EffectExtractor.scala:323-376`). Note the chain extracts `_spawn` from the +effect EXPRESSION, not the evaluated result — the `definition` must be a literal machine. Replaces the +40-line inline child in `consumer.definition.json:101-153`: + +```ts +export const spawn = ( + ds: { childId: JsonLogicValue; definition: ProtoStateMachineDefinition; + initialData: Record; owners: JsonLogicValue }[], +): Record => ({ _spawn: ds }); +``` + +`owners` is the F8 gotcha: a spawned child's transitions are gated by `owners ∪ authorizedSigners` +(`riverdale-economy/README.md:103-107`), so the builder's doc-comment must say "every party that will +drive the child (e.g. bidders) must be in `owners`." Accept `definition` as the output of a nested +`machine().wireDefinition()` so the child is itself a typed template. + +**`emit(events)`** — NEW. Emits `{ _emit: [{ name, data, destination? }] }` (`ReservedKeys.scala:108-111`, +`EffectExtractor.scala:307-321`): + +```ts +export const emit = ( + es: { name: string; data: JsonLogicValue; destination?: string }[], +): Record => ({ _emit: es }); +``` + +Because these are builders, a typo is a TypeScript error, not a silent state field (F4). The catalog must +also export a `RESERVED_EFFECT_KEYS` set (`['_triggers','_spawn','_emit','_transferAsset','_scriptCall', +'_addDependency','_setDependencyActive']`, from `ReservedKeys.scala:12-18`) that Proposal 01's validator +checks against. + +### 1.4 `migration()` helper — removes F9 + +The migration context root is the BARE prior state — `FiberEngine.scala:300` +(`MeteredEvaluator.eval(expr, sm.stateData, Migration)`), so `{"var":""}` is the whole prior state and +`{"var":"loyaltyPoints"}` reads a field directly (NOT `state.loyaltyPoints` like effects do). This +asymmetry is the foot-gun. Helper (replaces `retailer-migration.json:1-3` / `fed-migration.json`): + +```ts +// src/templates/migration.ts + +/** Seed/overwrite top-level state fields on upgrade. Emits {"merge":[{"var":""},{...seedFields}]}. + * The root is the BARE prior state ({"var":""}), unlike an effect's `state.x` — this hides that. */ +export const seedFields = (fields: Record): unknown => + ({ merge: [{ var: '' }, fields] }); + +/** General transform with the bare-state root made explicit: priorState resolves to {"var":""}. */ +export const migration = (build: (priorState: { var: '' }) => unknown): unknown => + build({ var: '' }); +``` + +`seedFields({ loyaltyPoints: 0 })` ⇒ `{"merge":[{"var":""},{"loyaltyPoints":0}]}` — byte-identical to +`retailer-migration.json`. `migration` is `Option` on `UpgradeFiber` (`Updates.scala:90-95`), so omit it +when absent (do not send `null`). + +### 1.5 State-shape-with-defaults — removes F5 (ties to Proposal 01) + +A declared state shape with defaults serves THREE consumers: it seeds `initialData` (so accumulators are +never `null`), it feeds the dry-run validator (Proposal 01), and it can carry into the policy/machine +`stateShape`. This is the SDK half of Proposal 01's "declared state-shape with defaults": + +```ts +// src/templates/state-shape.ts + +export interface StateShape { fields: Record; } + +/** Build initialData by overlaying explicit values on the declared defaults — no field reads null. */ +export function seedState(shape: StateShape, overrides?: Record): Record; + +/** The set of state field names the effects may read — for Proposal 01's var-path checks. */ +export function declaredFields(shape: StateShape): Set; +``` + +`seedState({ fields: { purchaseCount: { default: 0 }, status: { default: 'debt_current' } } })` replaces +the hand-kept `consumer.initial.json`. + +--- + +## 2. The genesis std-lib + +A canonical, vetted set of `std.*` registry packages pre-registered at ordinal 0, so apps reference a +blessed package (verified-bound) instead of re-publishing one. Grounded in +`~/repos/ottochain/docs/proposals/genesis-and-engine-versioning.md`. + +### 2.1 Which packages to publish at genesis + +- **Policy presets as `std.*.asset` packages** — `std.fungible.asset`, `std.nft.asset`, + `std.soulbound.asset` (the §1.1 presets at `1.0.0`). An app's `MintAsset.policyRef` + (`types.ts:815-827`) then resolves `{ name: 'std.fungible.asset', version: {Exact:{version:'1.0.0'}} }` + instead of every app publishing its own `rvd.asset`. +- **Machine skeletons as `std.*.package`** — the existing `std.identity.package` / + `std.governance.package` / `std.markets.package` (`genesis-manifest.ts:269-298`), plus any riverdale + archetypes worth blessing (e.g. a generic `std.wallet.package`, `std.escrow.package`). These already + flow through the manifest path. + +Naming: `RegistryName` reserves the `std` label in-protocol (`RegistryName.isReserved`), so ordinary user +registrations of `std.*` are REJECTED — only the privileged genesis path may claim them +(`genesis-manifest.ts:23-28`, `genesis-and-engine-versioning.md:75-77`). That reservation is exactly what +makes genesis the trust root. + +### 2.2 How: the genesis registry-seeding mechanism + +The seam (`genesis-and-engine-versioning.md:16-77`): + +``` +pinned ottochain-sdk release + └─ buildGenesisManifest() → GenesisManifest (CONTENT: machineShape + definition; NO hashes) + └─ genesis.json (GenesisData codec) + └─ ML0Service.genesis loads it (GenesisLoader.load, GenesisLoader.scala) + └─ GenesisManifestLoader.fromManifest (computes schemaHash/logicHash with the chain's + OWN computeDigest) → GenesisBuilder.withPackages → DataState +``` + +What LANDED already: +- `GenesisBuilder.withPackages` builds a consistent genesis `DataState` (`registry` + `registryCommits` via + each entry's `computeDigest`), first version stamped `Active` at `SnapshotOrdinal.MinValue` + (`ottochain/.../genesis/GenesisBuilder.scala:30-105`). +- `GenesisManifestLoader.fromManifest` turns an SDK `GenesisManifest` into that genesis, computing + `logicHash = definition.computeDigest`, `schemaHash = machineShape.computeDigest` + (`GenesisManifestLoader.scala:24-45`). +- `GenesisLoader.load` reads the configured path, decodes `GenesisData`, or falls back to the empty genesis + (`GenesisLoader.scala`); wired into `ML0Service.make` (`ottochain/.../ML0Service.scala:57-70`). +- The SDK exporter `buildGenesisManifest` (`genesis-manifest.ts:269-298`) and the chain manifest model + `GenesisManifest`/`ManifestPackage` (`ottochain/.../schema/GenesisManifest.scala:20-34`). + +What MUST be BUILT for the policy std-lib (this is the chain-side delta, the P3 work): +- **Asset-policy genesis support.** Both `GenesisBuilder.PackageSpec` (`GenesisBuilder.scala:33-42`) and + the chain `ManifestPackage` (`GenesisManifest.scala:27-34`) carry ONLY `machineShape` and build + `RegistryTarget.SchemaPackage` with `RegistryShape.Machine` (`GenesisBuilder.scala:89`). An asset policy + is a `RegistryTarget.AssetPolicyPackage` whose version shape is `RegistryShape.AssetPolicy` + (`ottochain/.../registry/RegistryTarget.scala:38`, `SchemaShape.scala:115`), with + `schemaHash = logicHash = AssetPolicy.computeDigest` (mirror `AssetCombiner.scala:82-123`). So: + - Extend the manifest model with an asset-policy package arm (a sum: machine | asset), or a parallel + `assetPackages: List[AssetPolicyManifestPackage]` carrying `behavior`/`supply`/`morphisms`/`stateShape`. + - Extend `GenesisBuilder` with an `assetPolicy` PackageSpec arm that builds the `AssetPolicyPackage` + target and computes the digest the SAME way the combiner does (reuse `RegistryShape.AssetPolicy.computeDigest`). + - Extend the SDK `buildGenesisManifest` to emit the policy packages (content only — `behavior`/`supply`/ + `morphisms`/`stateShape`, from the §1.1 presets). +- The "genesis-prep tool" (off-chain, deferred per `genesis-and-engine-versioning.md:168`) that reads the + pinned SDK release and writes `genesis.json`. The SDK side is `buildGenesisManifest`; the chain side is + `GenesisManifestLoader`. The remaining glue is the CLI that calls one and serializes for the other. + +### 2.3 Ownership / governance + +`PackageSpec.owner` / `GenesisManifestLoader.fromManifest(owner = …)` set the `RegistryEntry.owner` +addresses (`GenesisBuilder.scala:39, 94`; `GenesisManifestLoader.scala:26`). For std packages this is a +governance/foundation address (the blessed-apps trust root — `genesis-and-engine-versioning.md:75-77`). +Decisions to record: (a) is `std.*` owner a single foundation key, a multisig, or `Set.empty` (immutable, +no future versions)? (b) may std packages be versioned post-genesis (owner publishes `v2`) or are they +frozen to genesis? Frozen is safest for reproducibility; pick it unless an upgrade story is needed. + +### 2.4 Determinism — genesis must be reproducible + +The genesis-prep pipeline is reproducible iff the SDK release → `genesis.json` is a pure function +(`genesis-and-engine-versioning.md:48-77`: "same release ⇒ byte-identical genesis"). Rules for the std-lib +builders: +- **No `Date.now()`, no `crypto.randomUUID()`, no `Math.random()`** anywhere in a template that feeds the + manifest. Std package names, versions, definitions, policy shapes are all fixed literals. (Riverdale + already learned this for instance ids — `ids.ts:1-10`.) +- **The SDK ships CONTENT, never consensus hashes.** `buildGenesisManifest` deliberately emits no + `logicHash`/`schemaHash` (`genesis-manifest.ts:1-44` DESIGN note) — the chain derives them via its own + `computeDigest`, so there is ZERO cross-language hash-parity risk. Keep this for asset policies: emit + `behavior`/`supply`/`morphisms`/`stateShape`, let the chain hash. This is the single most important + determinism property — do not replicate the chain's hashing in TS. +- **Pin the manifest schema version** (`GENESIS_MANIFEST_VERSION`, `genesis-manifest.ts:135`) and bump it on + any shape change. +- **Fixed iteration order.** Emit packages in a stable, declared order (the chain stores them in a + `SortedMap` keyed by name — `GenesisBuilder.scala:97-99` — so order is normalized on-chain, but keep the + manifest stable for byte-identical `genesis.json` diffs). + +--- + +## 3. App consumption + +The target developer experience: an app author imports templates and submits, never touching raw +JSON-Logic. + +```ts +import { + fungiblePolicy, machine, transition, effect, guard, + transferAsset, triggers, spawn, seedState, seedFields, +} from '@ottochain/sdk/templates'; +import { createAssetPolicyPayload, signTransaction } from '@ottochain/sdk'; + +// 1. A policy (or just reference the genesis std.fungible.asset — see below) +const rvd = fungiblePolicy({ name: 'rvd.asset', version: '1.0.0', mintable: true, burnable: true }); + +// 2. A versioned machine, ONE definition shared by publish + create (verified binding) +const consumer = machine({ + name: 'consumer.package', version: '1.0.0', schemaShape: consumerShape, + app: defineFiberApp({ + metadata: { name: 'Consumer', app: 'riverdale', type: 'consumer', version: '1.0.0' }, + states: { ACTIVE: { id: 'ACTIVE', isFinal: false }, debt_current: { id: 'debt_current', isFinal: false } }, + initialState: 'ACTIVE', + transitions: [ + transition({ + from: 'debt_current', to: 'debt_current', on: 'buy', + effect: effect( + { status: 'debt_current', purchaseCount: { '+': [{ var: 'state.purchaseCount' }, 1] } }, + triggers([{ target: { var: 'event.retailerId' }, event: 'process_sale', + payload: { buyerId: { var: 'machineId' }, quantity: { var: 'event.quantity' } } }]), + transferAsset([{ assetId: { var: 'event.payAssetId' }, recipient: { var: 'event.retailerId' } }]), + ), + }), + ], + }), +}); + +// 3. Submit: publish the version, then create the fiber bound to it +const pub = createPublishMachineVersionPayload(consumer.publishVersion({ strict: false })); +const create = createStateMachinePayload(consumer.create({ + fiberId: CONSUMER_ID, + initialData: seedState({ fields: { purchaseCount: { default: 0 }, status: { default: 'debt_current' } } }), +})); +await signTransaction(pub, bobKey); // then POST to DL1 /data +``` + +### 3.1 The verified-binding flow + +The load-bearing guarantee: `consumer.publishVersion().definition` and `consumer.create().definition` are +the SAME object (the skeleton owns it, §1.2), so the chain's `definition.computeDigest` equals the +registered `logicHash` and the bind admits the fiber (`Updates.scala:166-179` "a fiber referencing this +version is admitted only if its definition hashes to `logicHash`"). The riverdale README confirms the +discipline this automates: "the SAME definition file is used for both publish + create" +(`riverdale-economy/README.md:110-113`). For a GENESIS-published `std.*` package, the app skips +`publishVersion` and only `create`s with `schemaRef: { name: 'std.fungible.asset', version: +{Exact:{version:'1.0.0'}} }` — the SDK template that emits the app's definition MUST hash to the SAME +`logicHash` the genesis manifest produced (this is why the genesis manifest and the §1 templates share the +SAME `toWireDefinition`/preset code — one source of truth, no drift). + +### 3.2 Migration of `riverdale-economy` (the proof) + +The harness step DSL names `*.json` files (`example.ts:124-142`). Two migration options, pick per the +execution plan: +- **Minimal**: add a `build:templates` script that runs the §1 builders and writes the SAME `*.json` files + (a golden round-trip: builder output === current checked-in JSON, byte-for-byte). The harness is + unchanged; the JSON becomes generated, not hand-written. +- **Full**: teach the harness to accept an in-memory definition/policy object (not just a filename) and + pass `consumer.create(...)` / `fungiblePolicy(...)` directly. Larger harness change. + +The round-trip (builder → JSON === checked-in JSON) is the acceptance test either way: it proves the +templates emit the exact canonical the chain already accepts in the green lane. + +--- + +## 4. Safety & compatibility + +- **Additive.** Every builder is a pure function returning the same wire shape hand-rolled JSON produces. + The raw-JSON path keeps working; the harness file-name DSL is untouched in the minimal migration. No + chain change is needed for §1 (P2) at all. +- **Signed-canonical invariant (CLAUDE.md #1).** A template MUST emit byte-identical canonical to what the + chain re-derives: the SDK signs `batchSign(dropNulls(payload))` and the chain re-encodes + verifies over + `JCS(dropNulls(payload))`. Templates therefore: + - **Omit** absent optionals, never emit `null` (so `dropNulls` is a no-op on the difference) — + `migration`/`metadata`/`participants` (`Updates.scala:90-95`, `transaction.ts:266-271`). + - **Always send** required-no-default fields: `strict` on publish (`types.ts:600`), `morphisms` on + policy (`Updates.scala:251-263`), `dependencies: []` per transition, `repeated`/`optional` on every + `FieldShape` (`types.ts:146-152`). `toProtoDefinition` already guarantees the `dependencies`/`policy` + rules (`fiber-app.ts:646-688`); the new builders must hold the same line. + - **Reuse the existing projectors** (`toProtoDefinition`, `projectFiberPolicy`) rather than re-deriving + the wire shape, so there is one canonicalization path. + - **Any NEW optional field** added to a signed message stays `Option`/omit-safe and is added to the + chain's `PublishVersionSigningCanonicalSuite` (CLAUDE.md #1) — but §1 adds NO new message fields, only + builders that fill existing ones, so this risk is confined to §2.1's asset-policy genesis arm. +- **No registry-lineage reads leak into block acceptance (CLAUDE.md #3).** Templates and genesis seeding + touch only message construction and the genesis `DataState`; they add NO `validateSignedUpdate` logic, so + the TOCTOU block-poisoning hazard does not apply. (Asset-policy resolution stays combine-only — + `AssetCombiner.scala`.) +- **Std-lib versioning.** Genesis packages are pinned at `1.0.0` at ordinal 0. An app bound to + `std.fungible.asset@1.0.0` keeps resolving that exact version forever (existing pinned bindings keep + running even across status changes — `RegistryStatus` semantics, `types.ts:96-103`). A future genesis + that adds `std.fungible.asset@2.0.0` does NOT break the bound app. Choose §2.3's frozen-vs-owned model so + a genesis change is purely additive to lineage. + +--- + +## 5. Execution plan for the SDK agent + +Ordered, risk-ascending. **[SDK]** = `~/repos/ottochain-sdk`, **[CHAIN]** = `~/repos/ottochain`. + +### Phase A — SDK template library (P2, additive, no chain change) + +1. **[SDK]** Create `src/templates/` with subpath export `@ottochain/sdk/templates` (mirror + `src/schema/index.ts` and the `package.json` `exports` map). Re-export the existing builders + (`schema/effects.ts`, `schema/guards.ts`, `schema/fiber-app.ts`'s `defineFiberApp`/`toProtoDefinition`/ + `constrained`). +2. **[SDK]** `src/templates/asset-policy.ts` — `fungiblePolicy`/`nftPolicy`/`soulboundPolicy`/`customPolicy` + (§1.1). Behavior via `TOKEN_BEHAVIOR_BITS` (`types.ts:679-685`); emit `CreateAssetPolicy` (`types.ts:803-812`). +3. **[SDK]** Extend `src/schema/effects.ts` with `triggers`/`spawn`/`emit` + `toFiber`/`toWallet` and a + `RESERVED_EFFECT_KEYS` export (§1.3). Keys verbatim from `ReservedKeys.scala:12-49`. +4. **[SDK]** `src/templates/machine.ts` — `transition()`/`effect()`/`guard` re-export + the `machine()` + skeleton (§1.2); the skeleton reuses `toProtoDefinition` (`fiber-app.ts:641`) for the wire definition. + Add `createPublishMachineVersionPayload` if absent (envelope only). +5. **[SDK]** `src/templates/migration.ts` (`seedFields`/`migration`, §1.4) and `src/templates/state-shape.ts` + (`seedState`/`declaredFields`, §1.5). +6. **[SDK]** Tests: + - Unit: each builder emits the exact object (snapshot tests against the riverdale raw JSON — e.g. + `fungiblePolicy({name:'rvd.asset',…})` === `rvd-policy.json`). + - **Golden canonical round-trip** (the load-bearing one): for each template output, sign with + `signTransaction` and assert the `dropNulls`→JCS bytes match a fixture captured from the chain's green + lane (reuse `tests/ottochain/signing-parity.test.ts` machinery). This is the proof that templates + don't shift the signed canonical (§4). + - Verified-binding test: `machine().publishVersion().definition` === `machine().create().definition`. +7. **[SDK]** Migrate `riverdale-economy` (§3.2, minimal option first): a `build:templates` script that + regenerates the `*.json` files from builders and a CI check that the regenerated files === the + checked-in files (byte-for-byte). This is the end-to-end proof for Phase A. + +### Phase B — Genesis std-lib (P3, chain-touching, lands after A stabilizes) + +8. **[SDK]** Extend `buildGenesisManifest` (`genesis-manifest.ts:269`) to emit the policy std packages + (`std.fungible.asset` etc.) using the §1.1 presets — content only (`behavior`/`supply`/`morphisms`/ + `stateShape`), no hashes. +9. **[CHAIN]** Extend the manifest model `GenesisManifest`/`ManifestPackage` + (`schema/GenesisManifest.scala:20-34`) with an asset-policy package arm. +10. **[CHAIN]** Extend `GenesisBuilder.PackageSpec`/`build` (`genesis/GenesisBuilder.scala:33-105`) and + `GenesisManifestLoader.fromManifest` (`GenesisManifestLoader.scala:24-45`) to build a + `RegistryTarget.AssetPolicyPackage` with `RegistryShape.AssetPolicy` and + `schemaHash = logicHash = AssetPolicy.computeDigest`, mirroring `AssetCombiner.scala:82-123`. +11. **[CHAIN]** Tests: extend `GenesisBuilderSuite` (an asset-policy genesis entry is consistent — + `registry` ↔ `registryCommits`); a fiber/mint bound to a genesis `std.*.asset` resolves and admits. + Add a `PublishVersionSigningCanonicalSuite` case if any new signed-message field is introduced (§4). +12. **[CHAIN/SDK]** The genesis-prep CLI (deferred, `genesis-and-engine-versioning.md:168`): SDK release → + `genesis.json` → `ML0Service` HOCON path. Reproducibility test: same SDK release ⇒ byte-identical + `genesis.json`. +13. **[E2E]** Add a riverdale lane (or extend FLOW 1 P0) that boots from a genesis std-lib and has an app + reference `std.fungible.asset` instead of publishing `rvd.asset` — the genesis std-lib proof. + +### Cross-repo version pin (the SDK ↔ JAR compatibility) + +The JAR and the SDK release MUST match (durable ops fact): an environment is the triple (node binary +`engineVersion`, pinned SDK release `std.*` bytes, genesis pre-registering that set — +`genesis-and-engine-versioning.md:9-13`). Concretely: +- Phase B genesis is produced by a SPECIFIC pinned SDK release; the node binary that boots it must + understand that `engineVersion` (`genesis-and-engine-versioning.md:105-159`). Record the SDK release ↔ + JAR version pin alongside the `genesis.json` (e.g. in the manifest `metadata` and the deploy config). +- The §1 templates (Phase A) carry no version coupling — they emit shapes the current chain already + accepts (proven by the golden round-trip), so they ship on the SDK's own cadence. + +--- + +## 6. Open questions (the SDK agent must resolve) + +1. **Can genesis publish ASSET-POLICY registry entries today, or only machine packages?** As of this doc, + `GenesisBuilder.PackageSpec` and the chain `ManifestPackage` are machine-only + (`GenesisBuilder.scala:33-42, 89`; `GenesisManifest.scala:27-34`). Confirm whether the policy std-lib is + (a) a chain-side extension (steps 9–10), or (b) a post-genesis bootstrap transaction signed by the std + owner at ordinal 1 (no chain change, but std names are reserved against non-genesis publish — + `genesis-manifest.ts:23-28` — so the bootstrap would need a privileged path or a non-`std` name). Decide + the mechanism before Phase B. +2. **Genesis vs post-genesis for the FIRST environment.** `genesis-and-engine-versioning.md:163-171` lists + the `GenesisData` codec + `ML0Service.genesis` file-load as "deferred (#39)" — verify the file-load path + is actually wired (it appears to be: `GenesisLoader.scala` + `ML0Service.scala:57-70`). If the genesis + file path is not yet honored in the deployed JAR, Phase B's machine std-lib may already work in-process + but not from a config file. +3. **Std-package ownership/governance + frozen-vs-versioned** (§2.3): single key, multisig, or + `Set.empty`? May `std.*` get a `v2` post-genesis? This drives §4's versioning guarantee. +4. **Asset-policy `logicHash` stability across SDK refactors.** Since `logicHash = + AssetPolicy.computeDigest` over `behavior`/`supply`/`morphisms`/`stateShape` + (`AssetCombiner.scala:82-123`), ANY change to a preset's emitted shape changes the genesis `logicHash` + and breaks apps bound to it. Confirm the presets' output is frozen once published, and add a golden + fixture pinning each preset's exact JSON. +5. **Subpath vs root export.** Should the catalog be a distinct `@ottochain/sdk/templates` subpath (tree- + shakeable, opt-in) or merged into the root `index.ts`? The README handoff says `@ottochain/sdk/templates` + (`README.md:39`); confirm against the SDK's `exports` map + bundler conventions. +6. **`spawn` literal-definition constraint.** `EffectExtractor.extractSpawnDirectivesFromExpression` + (`EffectExtractor.scala:323-376`) reads `_spawn` from the effect EXPRESSION, requiring the child + `definition` to be a literal (not an evaluated value). Confirm the `spawn()` builder's child definition + survives `toProtoDefinition` projection without being treated as an expression to evaluate. +7. **Harness consumption depth** (§3.2): minimal (regenerate `*.json`) vs full (in-memory objects). The + full path needs a harness change to accept definition/policy objects in the step DSL (`example.ts:124-142`). + Decide before step 7/13. diff --git a/docs/proposals/fiber-ergonomics/01-authoring-safety.md b/docs/proposals/fiber-ergonomics/01-authoring-safety.md new file mode 100644 index 00000000..571607ce --- /dev/null +++ b/docs/proposals/fiber-ergonomics/01-authoring-safety.md @@ -0,0 +1,394 @@ +# Fiber Authoring Safety — Offline Definition Validator & State-Shape Defaults — RFC + +**Status:** draft / design. **Date:** 2026-06-25. **Addresses:** F4, F5, F9, F10 (see +[`README.md`](./README.md)). **Risk:** Low (additive advisory tooling — no chain change). + +A fiber definition is hand-written JSON-Logic (`states` / `transitions` with deeply nested +`{"var":…}` / `{"+":[…]}`). There is no offline safety net: a typo'd directive key, an unseeded +accumulator, a `var` into a path the engine never populates, an unreachable state, or a guard reading +an undeclared `machines.$id` all parse, sign, submit, and **only misbehave at combine** — silently, on +the cluster, with no error pointing at the source. This RFC proposes two additive moves, both off-chain +and non-consensus: + +- **(a) a pure, offline `validateDefinition`** — resolves every `var` path against the known context + roots, rejects unknown `_`-directive keys, checks reachability, checks effect-output conformance, and + surfaces the existing depth/gas limits — as structured diagnostics, before the message is signed. +- **(b) a declared STATE-SHAPE with DEFAULTS** — let a definition declare its state fields and their + zero-values (`0` / `""` / `[]`) so accumulators auto-initialize and the validator can check reads, + applied **off-chain when building `initialData`** so the signed canonical is unchanged. + +It **extends** the existing conformance machinery (`ConformanceChecker`, `MessageShape`, +`SchemaShape`) rather than reinventing it, and implements the *static* conformance check that +[`strong-typing-and-conformance.md`](./../strong-typing-and-conformance.md) §3 specified but never built. + +**Companion proposals:** +- [`00-sdk-stdlib-and-templates.md`](./00-sdk-stdlib-and-templates.md) — the SDK builders that *emit* + correct forms; this validator is what they validate against, and the safety net for everyone still + hand-rolling. +- [`strong-typing-and-conformance.md`](./../strong-typing-and-conformance.md) — the two dials (binding + strength / conformance) and the `SchemaShape` projection the validator and state-defaults plug into. +- [`schema-architecture.md`](./../schema-architecture.md) — the registry/schema commitment model. +- `docs/signing-canonical-and-validation.md` — invariant #1 (Option/omit-safe; no defaulted signed + fields), which the state-defaults design must respect. + +--- + +## 0. Today (baseline) + +A definition is authored as raw JSON (`e2e-test/examples/riverdale-economy/*.definition.json`), base64'd, +and submitted. Some **structural** sanity checks exist — but they run **on the cluster**, at +block-acceptance (`Validator.validateSignedUpdate`, +`modules/shared-data/.../lifecycle/Validator.scala`) and combine, never offline pre-send, and they do +**not** cover the footguns below. The checks that *do* exist, in +`modules/shared-data/.../lifecycle/validate/rules/FiberRules.scala`: + +- `L1.validStateMachineDefinition` (`FiberRules.scala:38`) — `states` non-empty; `initialState` is a + known state; every `transition.from`/`.to` references a declared state; no exact-duplicate and no + ambiguous (same `from`+`eventName`, unconditional) transitions. +- `L1.definitionWithinLimits` (`:96`) — `MaxStates` / `MaxTransitions` caps. +- `L1.definitionExpressionsWithinDepthLimits` (`:128`) — guard/effect expression depth caps. +- `L1.noReservedOperatorFieldNames` (`:149`) — a *state field* may not collide with a JSON-Logic + **operator** name (`var`, `+`, …). **Note:** this is the operator namespace, **not** the `_`-directive + set — a typo'd `_triggers` sails straight through it. + +None of these resolve `var` paths, validate directive-key spelling, check reachability, check +state-shape conformance of reads/writes, or run before the cluster sees the message. The five concrete +failure modes an author hits: + +### (i) Typo'd reserved key — silent, no error (F4) + +`ReservedKeys.isInternal(key) = key.startsWith("_")` +(`modules/models/.../schema/fiber/ReservedKeys.scala:121`). The effect object is dual-purpose: a +`_`-prefixed key is a directive, every other key merges into state. `StateMerger.mergeMapValue` filters +out **any** `_`-key before merging (`modules/shared-data/.../fiber/evaluation/StateMerger.scala:70`), +and `EffectExtractor` only ever pulls the **known** directive names (`_triggers`, `_spawn`, +`_scriptCall`, `_emit`, `_transferAsset`, `_addDependency`, `_setDependencyActive` — +`ReservedKeys.scala:12-18`, dispatched at `EffectExtractor.scala:77-91`). So there are **two** silent +failure modes, neither raising an error: + +| Mistake | `isInternal`? | Outcome | +|---|---|---| +| `"_triger": [...]` (misspell, underscore kept) | `true` → stripped by `StateMerger` | matched by **no** extractor → the trigger **never fires**, and it is **not** in state. Vanishes. | +| `"transferAsset": [...]` (dropped the underscore) | `false` → merged into state | becomes a **junk state field** named `transferAsset`; the transfer **never happens**. | + +`StateMerger`'s array form behaves identically (`StateMerger.scala:86-90`). The author sees a +transition that "succeeds" but does nothing, with no diagnostic. + +### (ii) Missing state field — null silently coerces to 0 (F5) + +`{"var":"state.taxesPaid"}` on a `taxesPaid` that was never seeded does **not** error. metakit's var +resolver returns `NullValue` for an absent map key +(`io.constellationnetwork.metagraph_sdk.json_logic` — `JsonLogicSemantics.getVar` → `getChild`, +`case None => NullValue.asRight`), and `lookupVar` only substitutes a default when the `{"var":["x",0]}` +two-arg form is used (`JsonLogicRuntime.lookupVar`). The null then flows into arithmetic, where +`NumericOps.promoteToNumeric(NullValue) = IntResult(0)` — so `{"+":[{"var":"state.taxesPaid"},…]}` +silently treats the missing accumulator as `0`. The author's only defenses today are **seed every +accumulator by hand** in `initialData` (e.g. `manufacturer.initial.json` carries `"taxesPaid": 0` for +the `{"+":[{"var":"state.taxesPaid"},…]}` read in `manufacturer.definition.json:38`), or use the +clunkier `{"var":["taxesPaid",0]}` default form. Forget one accumulator (`consumer.definition.json` +alone seeds `purchaseCount`, `paymentsMade`, `taxesPaid`, `activeListings`, `loanBalance`, `balance`, +`marketplaceSales`) and the bug is a wrong number, never an error. + +### (iii) `var` into a nonexistent path — same silent null + +A typo'd read — `{"var":"state.invetory"}`, or `{"var":"event.qty"}` when the command field is +`quantity` — resolves to `NullValue` by the exact same path (`getChild` `None => NullValue`), coerces +to `0`/empty, and a guard like `{">=":[{"var":"state.invetory"},{"var":"event.quantity"}]}` +(cf. `manufacturer.definition.json:12`) misfires (`0 >= q`) with no signal. + +### (iv) Unreachable / dead-end state + +`FiberRules` checks transitions reference *declared* states but **not** reachability from +`initialState`, nor that a non-`isFinal` state has an outgoing transition. A state added but never +wired (`marketplace_selling` reachable only via a `list_item` that was renamed) is dead weight the chain +happily accepts. + +### (v) Guard/effect referencing an undeclared `machines.$id` + +Cross-fiber reads are dependency-gated (F6): a `{"var":"machines..state.…"}` read resolves only if +`` is in the transition's declared `dependencies` — `ContextProvider.buildMachinesContext` populates +`machines` **only** from the declared dependency set +(`modules/shared-data/.../fiber/core/ContextProvider.scala:266`). Read a `machines.` that was never +declared (or a `parent.…` with no parent) and you get `NullValue`, silently — the asymmetry F6 +describes, with no author-time warning. + +The root cause (F10): the model is invisible until combine time. Every one of these is statically +detectable from the definition alone. + +--- + +## 1. Goals & non-goals + +**Goals.** +1. A **pure, offline, deterministic** `validateDefinition` that an author (or the SDK, or the e2e + runner) runs **before signing**, returning structured diagnostics that point at the offending + transition/path. +2. **Kill F4 outright**: an unknown `_`-key is a hard validator **error** (it can only be a typo or an + unimplemented directive). +3. **Catch F5/F10 before the cluster**: flag every `var` read of an undeclared state field, and let a + declared **state-shape with defaults** auto-initialize accumulators. +4. **Extend, don't reinvent**: reuse `MessageShape`/`SchemaShape` and the static-conformance design + already written in `strong-typing-and-conformance.md` §3. + +**Non-goals.** +- **No chain change, no consensus surface.** The validator is advisory tooling; it never gates block + acceptance or combine. (Whether a *strict* combine reject is offered later is an open question, §6.) +- **Not a type system for the JLVM logic.** Guards/effects stay generic `JsonLogicExpression` + (the "describe + bind, don't constrain" principle, `strong-typing-and-conformance.md` §0.5). The + validator checks *var-path resolvability and key spelling*, not totality or value types of arbitrary + expressions. +- **Not a replacement** for the runtime `ConformanceChecker` (which gates *produced values* at combine + for `strict` versions) — it is the complementary *static* gate over the *definition*. + +--- + +## 2. The validator — `validateDefinition` + +### Signature & placement + +A **pure** function — no `F[_]`, no `IO`, no `CalculatedState`, no network: + +```scala +def validateDefinition( + definition: StateMachineDefinition, // already a first-class typed value on-chain + shape: Option[MachineShape] = None, // the typed schema projection, if the author has one + deps: Option[DeclaredDeps] = None // declared machines.$id / scripts.$id, parent presence +): List[Diagnostic] // empty == clean; advisory, ordered, source-located +``` + +```scala +final case class Diagnostic( + severity: Severity, // Error | Warning | Info + code: String, // stable, e.g. "unknown-directive", "undeclared-state-read" + message: String, + location: Location // transitionIndex, field ("guard"|"effect"|"initialData"), var-path +) +sealed trait Severity +``` + +**Where it lives.** The vocabulary it needs — the reserved directive set, the context roots, the typed +`StateMachineDefinition`, `MessageShape`/`SchemaShape` — already lives in **ottochain** (`ReservedKeys`, +`schema/registry/SchemaShape.scala`, `ConformanceChecker`). So the **pure core** belongs alongside them +in ottochain (`modules/shared-data/.../fiber/`, next to `ConformanceChecker`), as a no-`F` object +(`DefinitionLinter` / `validateDefinition`). It is then consumed by: + +- **the e2e runner** — as a JVM **pre-send dry-run**: lint every `*.definition.json` before submitting, + fail fast with the diagnostic list (no cluster round-trip to discover a typo). +- **the SDK** (`@ottochain/sdk`, Proposal 00) — a **TypeScript mirror** of the same logic, so authors + get the check in their editor/CI; the SDK's typed builders emit forms that lint clean by construction. + +The single generic primitive it builds on — *"extract every `var`-path and every map-key from a +`JsonLogicExpression` AST"* (plus the existing expression-depth walk) — is logic-generic and could be +contributed to **metakit** as a pure helper, so the Scala and TS validators share one AST-walk spec. +(See §5 for metakit-vs-ottochain placement trade-offs.) The check is **non-consensus** either way: it +runs in tooling, never in `validateSignedUpdate` or a combiner. + +### What it checks + +**(a) Every `var` path resolves against a known root.** Extract every `{"var":"…"}` (and the +`{"var":["…",default]}` form) from every guard, effect, trigger payload, script-call args, and `_spawn` +sub-expression. Resolve the **first segment** against the context roots the engine actually injects +(`ReservedKeys.scala:63-97`, built in `ContextProvider.buildStateMachineContext`, +`ContextProvider.scala:164-186`): + +| Root | Resolves against | Source | +|---|---|---| +| `state.X` | the declared **state-shape** fields (else a Warning that the field is unseeded → reads null) | `ContextProvider.scala:166` | +| `event.X` | the **command shape** for *this* transition's `eventName` (`MachineShape.commands(eventName)`) | `:167` | +| `machines..…` | the transition's **declared `dependencies`** (else Error: undeclared cross-fiber read — failure mode (v)) | `:180`, `:266` | +| `scripts..…` | declared script dependencies | `:183`, `:287` | +| `parent.…` / `children.…` | present only if the fiber is spawned / has children | `:181-182` | +| `heldAssets..{behavior,amount,expiresAt}` | reserved asset projection (dynamic key open; leaf keys checked) | `:184`, `:108-124` | +| `$ordinal`, `$lastSnapshotHash`, `$epochProgress`, `$caller`, `proofs`, `machineId`, `currentStateId`, `sequenceNumber`, `eventName` | reserved roots — always present, well-typed | `:169-179` | + +An unknown first segment (`{"var":"stat.x"}`, `{"var":"machine.y"}`) is an **Error** — it can only ever +resolve to `null`. A known root with an undeclared sub-field (`state.taxesPaidd`) is the heart of +**(iii)**: an Error against a declared state-shape, a Warning without one. + +**(b) Every `_`-key is a KNOWN reserved directive (kills F4).** Walk every effect-output map. Any +key where `ReservedKeys.isInternal(key)` is `true` (`startsWith("_")`) **must** be one of the known +directive constants (`ReservedKeys.scala:12-18`). An unknown `_`-key — `_triger`, `_transfers`, +`_trigger` — is a hard **Error** (`code: "unknown-directive"`): the engine would silently strip it +(failure mode (i), first row). Symmetrically, a **non-`_` key whose name is one-edit-distance from a +known directive** (`transferAsset`, `triggers`, `spawn`) is a **Warning** (`"likely-dropped-underscore"`) +— failure mode (i), second row, where it becomes a junk state field. This is the check that closes F4. + +**(c) State / transition reachability.** BFS from `definition.initialState` over `transitions`; any +state not reached is an **Error** (`"unreachable-state"`). Any non-`isFinal` state with **no** outgoing +transition is a **Warning** (`"dead-end-state"`). Complements `FiberRules.validStateMachineDefinition` +(`:38`), which checks references but not reachability (failure mode (iv)). + +**(d) Conformance of effect output to the state-shape.** This implements +`strong-typing-and-conformance.md` §3's static conformance — *the check that was specified but never +built* (the runtime `ConformanceChecker` only checks produced **values** at combine, +`ConformanceChecker.scala:50`). Statically, given a `MachineShape`: + +- **Write conformance:** every effect-output key (minus `_`-directives) must be a declared field of + `shape.stateMessage` (reusing the `MessageShape`/`FieldShape` model, `SchemaShape.scala:24-36`). ⇒ the + machine never writes an undeclared field. (Mirrors `ConformanceChecker.check`, + `ConformanceChecker.scala:29`, but over *static keys* instead of a produced value.) +- **Read conformance:** every `var state.X` must be a declared `stateMessage` field (= (a) with a + shape). +- **Event conformance:** every `transition.eventName` must have a `commands[eventName]` message + (`MachineShape.commands`, `SchemaShape.scala:44-47`), and every `var event.X` must be a field of it. + +Without a `shape`, (d) degrades to *internal consistency* — a field that is **written** by some +transition is treated as a known state field for **read** checks, so an accumulator written in one +transition and read in another lints clean even with no schema. + +**(e) Expression-depth / gas sanity.** Surface the **existing** caps offline so the author sees them +before the cluster does: run `FiberRules.definitionExpressionsWithinDepthLimits` +(`FiberRules.scala:128`) and a static `FiberGasEstimator` pass +(`modules/shared-data/.../fiber/FiberGasEstimator.scala`) and report any guard/effect over the depth cap +or whose estimated worst-case gas exceeds the per-transition budget — as Errors (depth) / Warnings +(gas), not as new limits. + +### Diagnostic example + +``` +manufacturer.definition.json + ERROR unknown-directive t[0].effect key "_transferAssets" is not a directive + (did you mean "_transferAsset"?) + ERROR undeclared-state-read t[1].effect "taxesPaid" {"var":"state.taxesPaid"} — field not in + state-shape and never written (reads null → 0) + WARN undeclared-dep-read t[0].guard {"var":"machines..state.x"} — not + in transition.dependencies (resolves to null) + ERROR unreachable-state states.archived not reachable from initialState "stocked" +``` + +--- + +## 3. State-shape with defaults + +### The shape + +Let a definition (or its schema) declare its state fields **and their zero-values**, so accumulators +auto-initialize and (a)/(d) have a field list to check against. This is a thin extension of the existing +`MessageShape`/`FieldShape` (`SchemaShape.scala:24-36`) — conceptually a per-field `default`: + +```jsonc +// authoring-side state-shape (off-chain artifact, NOT a new on-chain field — see §4) +{ + "taxesPaid": { "type": "uint64", "default": 0 }, + "purchaseCount": { "type": "uint64", "default": 0 }, + "status": { "type": "string", "default": "" }, + "activeListings": { "type": "uint64", "default": 0 } +} +``` + +Defaults are the JSON-Logic zero-values — `0` (`IntValue`/`FloatValue`), `""` (`StrValue`), `[]` +(`ArrayValue`), `{}` (`MapValue`), matching how the absent-then-coerced read behaves today +(`NumericOps.promoteToNumeric(NullValue)=0`) — so applying a default is **semantics-preserving** for the +`{"+":[…]}` accumulator pattern, only now it is explicit and check-able. + +### When defaults are applied — OFF-CHAIN at create-time (recommended) + +Two options; we **prefer the off-chain/SDK path**: + +- **Off-chain (recommended).** The SDK / genesis builder merges the declared defaults under the + author-supplied `initialData` when constructing the `CreateStateMachine` message: + `effectiveInitialData = defaults ++ authorInitialData` (author values win). The **submitted, signed + message already carries the full state** — the chain sees an ordinary, fully-populated `initialData`, + identical to today's hand-seeded `manufacturer.initial.json`. **Zero chain change, zero canonical + change.** +- **Combiner (rejected).** Applying defaults inside the combiner at create would put a defaulting rule + on the consensus path and change what a bare submitted `initialData` canonicalizes to — exactly the + kind of decoder-side re-fill CLAUDE.md rule #1 forbids. Not pursued. + +The validator's (a)/(d) checks consult the **same** declared state-shape, so "field reads as null" +(failure mode (ii)) becomes either *auto-seeded* (default present) or a *diagnostic* (no default, no +write) — never a silent `0`. + +### Tie to existing machinery + +The state-shape **is** a `MessageShape` over `FieldShape`s (`SchemaShape.scala`); the validator's write/ +read conformance **is** the static form of `ConformanceChecker.check` (`ConformanceChecker.scala:29`). +The only new authoring concept is the per-field `default`, and it lives **off-chain** (see §4) — so the +on-chain `MessageShape` / strict `ConformanceChecker` gate are untouched. + +--- + +## 4. Safety & compatibility + +- **The validator is additive advisory tooling.** It introduces **no** new on-chain type, message, or + combiner branch, and is never called from `validateSignedUpdate` or a combiner. Hand-rolled JSON keeps + working unchanged; the validator only *reports*. Zero consensus risk. + +- **State-defaults must NOT shift the signed canonical.** Defaults are applied **when building + `initialData` off-chain**, so the submitted `CreateStateMachine` already carries the full state — the + chain decodes a normal, complete `initialData`, with no field it must re-fill. This is the direct + application of **CLAUDE.md rule #1** (a signed field is `Option`/omit-safe **or** required-no-default; + a defaulted field re-encodes to a different `JCS(dropNulls)` and breaks `InvalidSignature`). Concretely: + we do **not** add a `default` field to the on-chain `FieldShape`/`MessageShape` carried inside the + signed `PublishVersion` / `CreateAssetPolicy` messages (that *would* be a canonical change, and would + need a new case in `PublishVersionSigningCanonicalSuite`). The `default` lives only in the off-chain + authoring artifact / proto field-options; the chain never sees it. + +- **No `_`-key vocabulary drift.** The validator reads the directive set from `ReservedKeys` + (`ReservedKeys.scala:12-18`) — the same source the engine dispatches on — so it cannot disagree with + the runtime about which `_`-keys are real. + +--- + +## 5. Alternatives & effort + +**Full proto-typed state vs. lightweight shape.** We could generate a full state struct from the proto +descriptor and run a complete type-checker over every expression. Rejected: the chain already commits +only the **shallow** `MessageShape` projection (top-level fields + immediate primitive types, +`ConformanceChecker.scala:18`, `strong-typing-and-conformance.md` §2), and that is exactly what the +var-path/effect-key checks need. The lightweight shape is already on-chain, deterministic, and +sufficient; a full proto typechecker is heavier tooling for diminishing returns and is the +"over-constrain the computation" trap §0.5 warns against. + +**Validator in metakit vs. ottochain-models vs. SDK-only.** +- *metakit* — maximally reusable, but the reserved-directive set and context roots are **ottochain's** + (`ReservedKeys`), so metakit would need them injected; only the generic *AST var-path/depth walk* is + truly metakit-shaped. ⇒ contribute that one primitive to metakit; keep the ottochain-specific tables + out. +- *ottochain-models/shared-data (recommended)* — the natural home: it already has `ReservedKeys`, + `StateMachineDefinition`, `SchemaShape`, `ConformanceChecker`. A pure object here is callable by the + e2e runner directly. +- *SDK-only* — needed anyway (TS mirror, for editor/CI), but if it were the *only* home the JVM e2e + runner couldn't dry-run. ⇒ build both: pure Scala core in ottochain + a TS mirror in the SDK, sharing + the metakit AST-walk spec. + +**Effort: Low.** Pure functions over types that already exist; property tests over the riverdale +definitions (every `*.definition.json` must lint clean; a corrupted copy with `_triger` / +`state.unseeded` must produce the matching diagnostic). No consensus code, no migration, no canonical +change. Estimated a few hundred LOC for the Scala core + the TS mirror, plus the off-chain default-merge +in the SDK builder. + +--- + +## 6. Open questions + +1. **Should an unknown `_`-key be a HARD combine reject too, behind a `FiberPolicy` flag?** The validator + makes it an offline Error, but a malicious/careless author can still submit one and the engine + silently strips it. A `strict-directives` opt-in on `FiberPolicy` could make an unknown `_`-key a + graceful `CombineRejected → RejectionReceipt` (combiner-only, per CLAUDE.md rules #2/#3 — never in + `validateSignedUpdate`, to avoid the TOCTOU block-poisoning hazard). Default off (additive); only + apps that want fail-closed directive hygiene opt in. + +2. **Severity policy for the e2e runner — fail or warn?** Should the pre-send dry-run *block* submission + on any `Error`, or only on a configurable subset? A reachability Error is a hard bug; an + `undeclared-dep-read` Warning may be intentional during exploration. Proposed: Errors block, Warnings + print, both overridable per-run. + +3. **Where do state-defaults live as the single source of truth** — a proto field-option (so codegen, + the SDK builder, and the validator all derive from one descriptor), or a sidecar + `*.state-shape.json`? The proto-option route keeps the three artifacts aligned + (`strong-typing-and-conformance.md` §4) but couples authoring to the proto toolchain. + +4. **Dynamic-key roots (`machines.`, `heldAssets.`).** The validator can resolve the *root* and + the *leaf* keys but not the instance `` (unknown at author time). How strict on the + `_policy`/`heldAssets` sub-trees, and should the validator accept a supplied "expected dependency + set" to tighten `machines.` checking beyond "is it in `dependencies`"? + +5. **Spawned-child `initialData` is built on-chain, so off-chain default-application can't reach it.** + A `_spawn` directive's `initialData` is evaluated by the engine from the parent's effect context + (`EffectExtractor.extractSpawnDirectivesFromExpression`, `EffectExtractor.scala:323`; + `consumer.definition.json:145` builds a child's `initialData` inline). The SDK can't pre-merge + defaults into a child that doesn't exist until the parent transitions. Do spawned children need a + different defaulting story (validator-only checking of the inline `initialData`, or a combiner-side + default keyed off the child's bound shape), and does that reopen the canonical question for the + spawn path? diff --git a/docs/proposals/fiber-ergonomics/02-asset-effect-ergonomics.md b/docs/proposals/fiber-ergonomics/02-asset-effect-ergonomics.md new file mode 100644 index 00000000..bc6eeb77 --- /dev/null +++ b/docs/proposals/fiber-ergonomics/02-asset-effect-ergonomics.md @@ -0,0 +1,305 @@ +# Asset-Effect Ergonomics — RFC + +**Status:** draft / design. **Date:** 2026-06-25. **Theme:** findings **F1, F2, F3** of +[`README.md`](./README.md). **Builds on:** [`../asset-model.md`](../asset-model.md) (the R1 custody +model, typed morphisms, `_transferAsset` return channel — read §4/§9/§10 first; this RFC refines, it does +not restate). **Sibling docs:** [`00-sdk-stdlib-and-templates.md`](./00-sdk-stdlib-and-templates.md) (the +SDK helpers this proposes), [`01-authoring-safety.md`](./01-authoring-safety.md) (the dry-run validator). + +This RFC is about **ergonomics, not safety**. The custody invariant at the core of all three findings +(R1 — a fiber's value moves only under its own transition logic) is **sound and stays**. The friction is +that the model is shaped inconsistently with itself (F2), and invisible/undocumented until combine time +(F1, F3). Every change here is **additive** and **canonical-safe** (§4 traces why). + +--- + +## 1. Today (baseline) + +There are **two** ways an asset's custody changes, gated by **who holds it**, and they are shaped +differently from each other. + +### 1a. Wallet-held → `ApplyMorphism` (a signed message) + +A wallet-held asset moves via a signed `Updates.ApplyMorphism` `OttochainMessage` +(`modules/models/.../schema/Updates.scala:299`). The combiner's first gate is **holder-ownership (R1)**: + +```scala +// AssetCombiner.scala:858 — requireWalletHolder (the gate, verbatim) +private def requireWalletHolder(source: AssetRecord, signers: Set[Address]): F[Unit] = + source.holder match { + case AssetHolder.Wallet(addr) => + raiseRejected(signers.contains(addr), s"not asset holder of ${source.assetId}") + case AssetHolder.Fiber(_) => + Async[F].raiseError( + CombineRejected("fiber-held assets move only via fiber transitions — phase 5") + ) + } +``` + +`requireWalletHolder` gates **every** `ApplyMorphism` kind — it is called once per morphism +(`AssetCombiner.scala:257`), per `AuthorizeCompose` (`:326`), and per part of a `Pool` +(`:796`, via `parts.traverse_(requireWalletHolder(_, signers))`). **A fiber-held asset's raw +`ApplyMorphism` is therefore unconditionally rejected** — there is no signer who can authorize a morphism +on it, because no wallet holds the fiber's key. + +### 1b. Fiber-held → `_transferAsset` (an effect directive) + +A fiber-held asset moves **only** through the `_transferAsset` effect directive, emitted from inside a +fiber transition's `effect` expression and landing in `AssetCombiner.applyFiberTransfer` +(`AssetCombiner.scala:413`). That path re-derives the same R1 defense, but keyed on the **emitting +fiber** rather than a signer: + +```scala +// AssetCombiner.scala:429 — the R1 defense for the fiber channel (verbatim) +_ <- raiseRejected( + source.holder == AssetHolder.Fiber(emittingFiberId), + s"fiber $emittingFiberId does not hold asset ${transfer.assetId} (holder=${source.holder}) — transfer rejected" +) +``` + +then `behavior.transferable` (`:435`), the optional `FiberPolicy.transferPolicy` recipient allowlist +(`:444`), recipient-fiber liveness (`:462`), and finally `holder := recipient` (`:471`). It is a +**whole-instance custody move**: `FiberEffect.AssetTransferred(assetId, recipient: AssetHolder)` +(`modules/models/.../schema/fiber/FiberEffect.scala:45`) carries **no amount** — the `TransferPolicy` +doc-comment is explicit: *"a whole-record custody move with no amount, so the only meaningful dial is WHO +may receive"* (`modules/models/.../schema/fiber/FiberPolicy.scala`). There is no fiber-initiated `Burn`, +`Fractionalize`, or partial transfer; and **a fiber cannot mint** (minting is a wallet-signed +`MintAsset`), so a fiber's held balance is fixed at whatever was transferred/minted into it. + +### Why R1 exists (and why we keep it) + +`requireWalletHolder` and the `applyFiberTransfer` holder check are **the same invariant** seen from the +two custody forms: *value may move only under the authority of whoever holds it.* For a wallet that +authority is a signature; for a fiber **there is no key** — the fiber's transition logic (its guard plus +the fact that the transition ran) **is** the authorization (asset-model.md §10, "Authorization chain"). +If raw `ApplyMorphism` on a fiber-held asset were allowed, any external signer could drain a vault/escrow +the fiber is supposed to govern. `EffectExtractor` carries **zero** authorization — it scrapes the +reserved key verbatim — so the combiner *must* never trust it (asset-model.md §9, "the single highest-risk +item"). **This RFC proposes nothing that removes `requireWalletHolder` for external `ApplyMorphism`.** + +### The three frictions + +- **F1 — custody is bifurcated.** A fiber-held asset can take *only* a whole-instance `_transferAsset`; + it cannot `Fractionalize`/`Burn`/partially-transfer without first being moved out to a wallet, split + there, and (optionally) moved back — a wallet round-trip. The riverdale economy worked around this by + **minting separate whole RVD instances per payment leg** instead of fractionalizing one held balance. +- **F2 — the recipient is a bare string.** `_transferAsset`'s `recipient` is a single string that + `EffectExtractor.parseRecipient` disambiguates (UUID → `Fiber`, DAG address → `Wallet`) — shaped + **unlike** the `{Fiber:{fiberId}}` / `{Wallet:{address}}` `AssetHolder` object used everywhere else. +- **F3 — custody decides whose effect transfers.** Because `applyFiberTransfer` requires + `holder == Fiber(emittingFiberId)`, the **GOODS → consumer** leg of a sale had to be emitted by the + *retailer's* `process_sale` effect (the retailer fiber held the GOODS), not by the consumer's `buy`. + The placement is correct but unobvious, and there is no template that encodes it. + +--- + +## 2. F2 — canonicalize the recipient + +### The surprise, exactly + +`_transferAsset` is extracted by `EffectExtractor.extractAssetTransfers` (`EffectExtractor.scala:188`); +the recipient is resolved and disambiguated by `parseRecipient`: + +```scala +// EffectExtractor.scala:242 — parseRecipient (verbatim) +private def parseRecipient(s: String): Option[AssetHolder] = + scala.util.Try(UUID.fromString(s)).toOption match { + case Some(uuid) => Some(AssetHolder.Fiber(uuid)) + case None => refineV[DAGAddressRefined](s).toOption.map(refined => AssetHolder.Wallet(Address(refined))) + } +``` + +So inside a directive the author writes a **bare string**: + +```json +{ "_transferAsset": [ { "assetId": { "var": "state.goodsId" }, + "recipient": { "var": "state.buyer" } } ] } +``` + +but the value it becomes — and the value every *other* surface uses — is the **`AssetHolder` object** +(`modules/models/.../schema/asset/AssetHolder.scala:24`, wire form +`{"Wallet":{"address":..}}` / `{"Fiber":{"fiberId":..}}`; the SDK type at +`ottochain-sdk/src/ottochain/types.ts:723` and the `walletHolder`/`fiberHolder` builders at +`ottochain-sdk/src/apps/lending/assets.ts:31`). `MintAsset.holder`, `ApplyMorphism.recipient`, and +`AssetRecord.holder` are all the object form; only `_transferAsset` is the bare string. (A *third* shape +exists in passing — the guard-context `holderJlv` emits lowercase `{"wallet":..}`/`{"fiber":..}` at +`AssetCombiner.scala:1098` — out of scope here, but the same "one concept, three encodings" smell.) + +### Proposal: accept the object form alongside the bare string + +In `parseAssetTransfer` (`EffectExtractor.scala:201`), the recipient expression is evaluated and then +required to be a `StrValue` before `parseRecipient` runs (`:229`). Widen that single step to accept +**both**: + +- `StrValue(s)` → `parseRecipient(s)` (unchanged — the bare-string path stays). +- `MapValue(_)` matching the canonical `AssetHolder` wire form (`{"Fiber":{"fiberId": }}` or + `{"Wallet":{"address": }}`) → decode straight to `AssetHolder` (reuse the magnolia + `AssetHolder` decoder, or a small explicit match on the single-key variant). + +Authors who already think in `AssetHolder` (everyone holding a `fiberHolder(...)`/`walletHolder(...)` from +the SDK) can pass it directly; the bare string keeps working with a **soft-deprecation doc note** +("prefer the object form; the bare string remains supported"). This is the F2 fix in full — one +additional `case` in one private parser. + +### Confirm it is additive + canonical-safe + +The directive lives in the fiber **definition's** `transition.effect` expression, which is evaluated +**pre-combine** at `FiberEvaluator.evaluateEffectExpression` (`FiberEvaluator.scala:257`) → +`EffectExtractor.extractEffects` (`:305`). The **signed message** that triggers all this is +`TransitionStateMachine(fiberId, eventName, payload, targetSequenceNumber)` +(`Updates.scala:65`) — it carries an event name and payload, **never the directive or its recipient**. +The recipient form is therefore *never* a signed-message field; it is an interpretation detail of the +already-registered definition, resolved at evaluation time. Widening `parseAssetTransfer` changes no wire +shape on any `OttochainMessage`, touches no canonical, and re-uses the existing (already-canonical) +`AssetHolder` codec. Existing definitions emitting bare strings re-evaluate identically. **Additive and +canonical-safe by construction** (full trace + CLAUDE.md rule mapping in §4). + +--- + +## 3. F1 / F3 — soften where safe + make legible + +Two options, each with a verdict. They are independent; (a) is recommended unconditionally, (b) is +conditional. + +### Option (a) — document the boundary + ship SDK helpers/templates · **RECOMMENDED, do first** + +Most of F1/F3's cost is *invisibility*, not the model. Fixes, all off-chain (Proposal 00 territory): + +1. **Doc-comments at the surprising sites** (P0, zero risk): `requireWalletHolder` + (`AssetCombiner.scala:858` — "a fiber-held asset takes NO raw `ApplyMorphism`; it moves only via + `_transferAsset`"), `parseRecipient` (`EffectExtractor.scala:242` — the bare-string disambiguation + + the object-form alias from §2), and `applyFiberTransfer` (`AssetCombiner.scala:413` — "the emitting + fiber must be the holder; this is why a transfer leg lives on the holder's effect"). +2. **SDK builders** (Proposal 00): `transferAsset(assetId, toFiber(id) | toWallet(addr))` that emits the + canonical object-form directive from §2, so authors never hand-encode the recipient or guess UUID-vs- + address. Mirror the existing `fiberHolder`/`walletHolder` helpers (`lending/assets.ts:31`). +3. **A custody-aware "sell" template** (Proposal 00) that encodes F3 directly: a sale is *two* custody + legs — the **payment** leg (buyer → seller) and the **goods** leg (seller's fiber → buyer) — and the + template places each `_transferAsset` on **the fiber that custodies that asset**, because + `applyFiberTransfer` will reject it otherwise. This turns F3 from a debugging surprise into a filled-in + template slot. A "custody table" doc (asset → holding fiber → which effect may move it) backs it. + +**Verdict: ship (a) first.** It removes the bulk of F1/F3's real cost (legibility) at zero consensus risk, +and it is a prerequisite for evaluating whether (b) is even needed. + +### Option (b) — fiber-initiated value transforms via new effect directives · **CONDITIONAL, defer** + +To let an app subdivide or destroy *fiber-held* value without a wallet round-trip, add effect directives +that run **inside the fiber's transition** — so the **fiber authorizes its own** split/burn, exactly as +`_transferAsset` lets it authorize its own custody move. R1 is preserved: the authority is still "the +holder's own transition logic," never an external signer. Concretely: + +- **`_burnHeld`** → new `FiberEffect.AssetBurned(assetId)`, extracted like `AssetTransferred`, applied by + a new `applyFiberBurn` that **mirrors `applyFiberTransfer`'s R1 defense** (`holder == Fiber(emitter)`), + then runs the existing `Burn` codomain — evaluate the policy's `burnPolicy` guard and `removeAsset` + (the logic already exists at `AssetCombiner.scala:523`). +- **`_splitHeld`** (the genuinely useful one) → `FiberEffect.AssetSplit(assetId, amounts | shardIds)`, + applied by `applyFiberSplit` mirroring the R1 defense + the `behavior.splittable` gate, then the + `Fractionalize` codomain — partition `amount`, shards inherit `behavior` with `combinable = false` + (existing logic at `:551`) — with **all shards staying fiber-held** (`holder = the same Fiber`). + +**Feasibility against `AssetCombiner`:** + +- The pattern is established: `applyFiberTransfers` (`:379`) already drives a list of fiber-emitted + mutations deterministically (sorted by emitter id, then list order), bounded by + `ExecutionLimits.maxAssetMutations`, single-pass / non-reentrant (asset-model.md §9). New directives + slot into the same driver and inherit those bounds. +- **Gap (the real cost):** today's `Fractionalize` partitions the amount **evenly** across `shardIds` + (`AssetCombiner.scala:563`, *"remainder goes to the first shard"*). The riverdale need is *"split off + exactly amount X for this leg"* — an **amount-aware** split the morphism does not currently express. So + `_splitHeld` is not a thin wrapper over `applyFractionalize`; it needs an explicit per-shard `amounts` + vector with a conservation check (`Σ shard.amount == source.amount`). That is a new codomain, not a + reuse. +- Each directive also needs: a new `EffectKind` for the fail-closed `allowedEffects` gate + (`FiberPolicy.scala:22` lists `Trigger/Spawn/Emit/Transfer/Dependency`; the gate runs at + `FiberEvaluator.scala:~338`), a gas phase (reuse `GasExhaustionPhase.Morphism`), `committedView` + coverage (assets are already a total key — no new projection), and golden round-trip + a riverdale e2e + lane. + +**Worth it?** The whole-instance + **separate-mint** pattern the riverdale economy already used *does* +express the need — each payment leg becomes its own auditable `AssetRecord` with its own provenance, +which is arguably *better* than splintering one instance. (b) is therefore **net-new combiner state- +transition surface — the riskiest layer — for a need the existing pattern already covers.** **Verdict: +defer.** Build (b) only if a concrete app hits a wall the pre-mint/whole-instance pattern cannot express +ergonomically — e.g. an AMM/vault that must split a *single* held balance at runtime by a *computed* +amount it could not pre-mint. If/when built: start with `_burnHeld` (trivial, low risk), then amount-aware +`_splitHeld`, **one at a time, behind golden + e2e**, per the README's P4 ordering. Never as a bundle. + +--- + +## 4. Safety & compatibility + +**Additive.** The bare-string recipient still parses (§2 widens, never narrows). The (b) directives are +opt-in: a definition that never emits `_burnHeld`/`_splitHeld` is byte-identical and behaves identically; +the fail-closed `allowedEffects` gate means a policy that does not list the new `EffectKind`s *rejects* +them rather than silently honoring them. + +**The signed canonical is unaffected — traced.** The submitted, signed object for a fiber transition is +`TransitionStateMachine(fiberId, eventName, payload, targetSequenceNumber)` (`Updates.scala:65`). Its +canonical is the JCS(`dropNulls`) of *those four fields*. The `_transferAsset` (and any new +`_burnHeld`/`_splitHeld`) directive is **not** in that message — it lives in the **registered +definition's** `transition.effect` (set at `CreateStateMachine.definition` / `PublishMachineVersion`, +`Updates.scala:47`/`:167`) and is evaluated **pre-combine** at `FiberEvaluator.scala:257` → `:305`. So: + +- **F2** changes only `EffectExtractor.parseAssetTransfer` (evaluation-time interpretation of a value + already produced inside the VM). No `OttochainMessage` field changes shape or default → **CLAUDE.md + rule #1** (signed fields are `Option`/required-no-default) is untouched. +- **(b)** adds new `FiberEffect` *variants* (in-process engine types, never a signed canonical — exactly + the property asset-model.md §9 relies on for `FiberResult.assetTransfers`) plus new combiner code. No + signed message gains a field. +- All of this is **combiner/evaluator** code. None of it is `validateSignedUpdate`, and none of it adds a + `CalculatedState.registry` *lineage* read to the block-acceptance gate → **CLAUDE.md rule #3** (no + registry-lineage reads in `validateSignedUpdate`; lineage checks stay combine-only) is untouched. The + stateful holder/policy reads these directives need already live in the combiner as graceful + `CombineRejected` (asset-model.md §6 "the rule #3 boundary"). + +**Guardrail for (b):** any new fiber-emitted mutation MUST be applied through the same single-pass / +non-reentrant driver as `applyFiberTransfers` (`AssetCombiner.scala:379`) and counted against +`ExecutionLimits.maxAssetMutations`, or it reopens the reentrancy/DoS surface §9 closed. + +--- + +## 5. Alternatives, effort/risk, open questions + +### Alternatives considered + +- **F2: rewrite `_transferAsset` to take *only* the object form (drop the bare string).** Rejected — + breaks every existing definition emitting a bare string (e.g. `lending-zk-loan.ts:386`, + `recipient: { var: "state.borrower" }`). The accept-both alias is strictly safer and just as legible. +- **F1: relax `requireWalletHolder` to let a fiber's *owner* sign an `ApplyMorphism` on a fiber-held + asset.** Rejected — re-introduces an external-signer authority over fiber-custodied value, the exact + hole R1 closes; and "fiber owner" is not the fiber's custody authority (the transition logic is). +- **F1: a fiber-initiated `MintAsset`.** Out of scope — minting is supply policy, wallet-signed by + design; a fiber that needs more units should be minted *into* (`MintAsset(holder = Fiber(id))`, + already allowed, asset-model.md §10 "Minting directly into a fiber"). + +### Effort / risk + +| Change | Surface | Risk | Order | +|---|---|---|---| +| Doc-comments (a.1) | comments only | none | P0 | +| SDK `transferAsset()` + sell template (a.2/a.3) | SDK (Proposal 00) | low (off-chain) | P2 | +| F2 accept object-form recipient (§2) | `EffectExtractor.parseAssetTransfer` (one `case`) | low (pre-combine, canonical-safe) | P4 | +| (b) `_burnHeld` | new `FiberEffect` + `applyFiberBurn` + `EffectKind` + tests | med (combiner state-transition) | conditional, after F2 | +| (b) `_splitHeld` (amount-aware) | as above + new conservation codomain | med-high (new codomain) | conditional, last | + +### Open questions + +1. **Object-form recipient validation depth.** Should `parseAssetTransfer` reuse the full magnolia + `AssetHolder` decoder (strict — rejects unknown keys) or match the single-key variant by hand + (lenient)? Strict is safer but must still *fail-silent-drop* a malformed directive to match the + existing extractor contract (`EffectExtractor.scala:182`). Lean strict. +2. **Soft-deprecation surface.** Bare-string recipients are dropped silently when malformed today; an + object-form typo would do the same. Does the dry-run validator (Proposal 01) flag a recipient that is + neither a resolvable bare string nor a well-formed `AssetHolder` object at authoring time? (It should — + this is exactly the F4-class "silent drop" the validator targets.) +3. **`_splitHeld` amount source.** If (b) is built: do shard amounts come from the directive (explicit + `amounts`, conservation-checked) or from a computed expression evaluated against the transition context + (more powerful, more gas, more validation)? The riverdale "split off leg amount X" need wants the + computed form — confirm against a concrete app before committing the shape. +4. **Should the guard-context `holderJlv` lowercase form (`{"wallet":..}`/`{"fiber":..}`, + `AssetCombiner.scala:1098`) be canonicalized to the `AssetHolder` wire form too**, for one consistent + holder encoding everywhere? Additive (guard authors read context keys), but it is a third shape worth + folding into the F2 cleanup — tracked here, not proposed. + + diff --git a/docs/proposals/fiber-ergonomics/03-cross-fiber-and-authorization.md b/docs/proposals/fiber-ergonomics/03-cross-fiber-and-authorization.md new file mode 100644 index 00000000..258bc2ba --- /dev/null +++ b/docs/proposals/fiber-ergonomics/03-cross-fiber-and-authorization.md @@ -0,0 +1,546 @@ +# 03 — Cross-fiber consistency & the authorization model — RFC + +**Status:** draft / design. **Date:** 2026-06-25. **Program:** [Fiber & Asset Authoring Ergonomics](./README.md). +**Addresses:** findings **F6** (trigger-vs-read dependency asymmetry), **F7** (transition authorization), +**F8** (spawned-child owners). Doc only — no implementation here. + +This RFC sits on `jlvm-engine-foundations.md` (the evaluator/effect substrate — the `ContextProvider` +context build, the `TriggerDispatcher` cascade-to-fixpoint, effects-as-data) and follows the format of +`asset-model.md` (a precise **Today / baseline** section, then additive, back-compatible proposals that +respect the three `signing-canonical-and-validation.md` invariants). + +The headline deliverable is **§1 — the authorization matrix**, because the model turned out to be +*non-obvious AND internally inconsistent*: authoring the riverdale-economy e2e produced F7 ("a transition +is not owner-gated — any signer can transition any fiber"), and a unit test +(`TransitionOwnerGateDivergenceSuite`, §1) **confirms F7 for the live apply path** while showing the +*validator* codes the opposite. The two halves of the chain disagree with each other. The matrix below is +the corrected, cited, **test-settled** ground truth, and §3 reframes F7 as a real **enforcement gap** +(likely a security bug) plus the proposed fix — the most important clarification in this document. + +> **Correction to an earlier draft.** A prior version of this RFC "reconciled" F7 by claiming the owner gate +> is real but *invisible in a single-key harness*. **That was wrong** and is retracted: the riverdale e2e +> used **distinct** keys (Alice creates → `owners = {Alice}`; Bob signs the transition) and Bob's transition +> **still applied** — because the **combiner** (the live apply path) enforces no owner gate at all. The +> divergence is by code path, not by key reuse. See §1's test note and §3.1. + +--- + +## 0. Today (baseline) — how a fiber talks to, and is authorized by, another fiber + +A fiber interacts with the rest of the metagraph along two cross-fiber axes and one authorization axis: + +- **Fire-at another fiber** — a transition's effect emits a `_triggers` directive (`ReservedKeys.TRIGGERS`, + `modules/.../schema/fiber/ReservedKeys.scala:12`) naming a `targetMachineId` + (`ReservedKeys.scala:25`). The engine routes it. +- **Look-at another fiber** — a guard/effect reads `machines.$id.state` (`ReservedKeys.MACHINES = "machines"`, + `ReservedKeys.scala:81`). The engine must first *project* that fiber into the evaluation context. +- **Be authorized to drive a fiber** — who may submit a `TransitionStateMachine` (or create/archive/upgrade) + is decided by the validator + combiner. + +All three are governed by **different gates with different ceremony**, and that inconsistency is the +subject of this RFC. + +### The trigger path (fire-at) — no dependency, gated by `acceptedCallers` + +`EffectExtractor.parseTriggerEvent` reads the directive's `targetMachineId` and builds a `FiberTrigger` +(`modules/.../fiber/evaluation/EffectExtractor.scala:115-124`). `TriggerDispatcher.processSingleTrigger` +routes purely by id — it looks the target up directly in the evolving `CalculatedState`, with **no +dependency check** of any kind: + +```scala +// modules/shared-data/.../fiber/triggers/TriggerDispatcher.scala:153 +state.getFiber(fiberId) match { + case None => … TriggerTargetNotFound … + case Some(fiber) => processWithHandler(trigger, fiber, state) // fires regardless of any declared dep +} +``` + +The *only* gate on the receiving side is the target fiber's `FiberPolicy.acceptedCallers`, checked +**before** any guard runs, in `FiberEvaluator.policyShortCircuit`: + +```scala +// modules/shared-data/.../fiber/evaluation/FiberEvaluator.scala:129-147 +lazy val callerHit = + (policy.flatMap(_.acceptedCallers), caller) match { + case (Some(allowed), Some(c)) if !allowed.contains(c) => + Some(FailureReason.PolicyViolation("acceptedCallers", s"caller $c is not in the accepted-callers allowlist")) + case _ => None + } +``` + +`acceptedCallers` is `Option[Set[UUID]]` (`modules/.../schema/fiber/FiberPolicy.scala:270`). **Unset ⇒ any +fiber may fire at you.** `$caller` is the engine-stamped, non-spoofable source fiber id (`ContextProvider` +`caller` param, `ContextProvider.scala:79`); a primary/external (wallet) trigger has `caller = None` and is +unaffected by `acceptedCallers`. **Net: firing at another fiber requires the emitter to declare nothing.** + +### The read path (look-at) — requires a declared dependency + +`machines.$id` is populated **only for declared dependencies**. The context builder projects exactly the +dependency set into `machines`, nothing more: + +```scala +// modules/shared-data/.../fiber/core/ContextProvider.scala:266-271 +private def buildMachinesContext(dependencies: Set[UUID]): F[MapValue] = + resolveFibers(dependencies, calculatedState.stateMachines.get, + (f: Records.StateMachineFiberRecord) => buildFiberSummary(f)) +``` + +and the dependency set handed to it is this transition's **static** dependencies ∪ the fiber's **active +dynamic** dependencies: + +```scala +// modules/shared-data/.../fiber/evaluation/FiberEvaluator.scala:172-175 +contextProvider.buildContext(fiber, input, proofs, + transition.dependencies ++ DependencyLedger.activeIds(fiber.dynamicDependencies)) +``` + +`Transition.dependencies: Set[UUID]` is a first-class, hash-pinned field of every transition +(`modules/.../schema/fiber/Transition.scala:19`, *"Other machines this transition reads from"*). A +`{"var":"machines..state"}` whose `` is **not** in `dependencies` resolves to `null` — the read +silently returns nothing. **Net: reading another fiber requires the reader to declare the dependency.** + +### The authorization path — who may drive a fiber + +The critical fact, settled by test (§1): the signer gate **diverges by code path** — the *validator* codes +it, the *combiner* (the live apply path) does not. + +| Update | Signer gate — **validator** (`validateSignedUpdate`) | Signer gate — **combiner** (live apply path) | Cited | +|--------|------------------------------------------------------|----------------------------------------------|-------| +| `CreateStateMachine` | none (anyone creates) | none — **establishes** `owners` from create proofs; `authorizedSigners = participants` | `FiberCombiner.createStateMachineFiber:50,76,64` | +| `TransitionStateMachine` | `owners ∪ authorizedSigners` (`updateSignedByOwnerOrParticipant`, `FiberRules.scala:299-319`, `:308`) | **NONE** — only sequence + the transition **guard** (`FiberCombiner.processFiberEvent:101-144`) | `TransitionOwnerGateDivergenceSuite`, `MultiPartyTransitionSigningSuite` | +| `ArchiveStateMachine` | **`owners` only** (`updateSignedByOwners`, `:272-288`) | sequence only (`archiveFiber:151-181`) | — | +| `UpgradeFiber` | **`owners` only** + binding/tighten/state (`FiberValidator.scala:131-140`) | sequence + binding/version + engine `UpgradeGate` (`migrationAuthority`) | — | +| Registry ops (`PublishMachineVersion`, `SetVersionStatus`, `RegisterAlias`, …) | structural-only here (rule #3; `Validator.scala:152`, `:172-175`) | **`RegistryEntry.owner` + lineage — enforced HERE** (authoritative) | — | +| Asset morphism / fiber-held asset (R1) | structural-only here (rule #3) | **policy + holder — enforced HERE** (authoritative) | `asset-model.md` §8/§10 | +| Spawned-child transition | child `owners` (= `spawn.resolvedOwners`, no participants) | **NONE** — same as `TransitionStateMachine`: sequence + guard only | `SpawnProcessor.createFiberRecord:142`; `SpawnValidator.resolveOwners:230-237` | + +The asymmetry is glaring: for **registry and asset** ops the owner/lineage gate lives **only in the combiner** +(by deliberate design — rule #3, the combiner is the authoritative deterministic gate). For +**transitions**, the owner gate lives **only in the validator** — the *opposite* placement — and the +combiner, which is what actually mutates committed state, enforces nothing but the guard. The transition +gate is therefore in the *wrong layer to be effective*. + +**The two surprises this RFC targets:** + +1. **F6 — opposite ceremony for fire-at vs look-at.** To *fire at* `B`, `A` declares **nothing** (B's + `acceptedCallers` is B's choice). To *read* `B`, `A` must declare `B` in `transition.dependencies`. + The reach-out is permissionless-by-default and dep-free; the read is dep-mandatory. An author who + wires a cross-fiber `_triggers` and then tries to also read the target's state in the **same** guard + hits a silent `null` until they additionally declare the dependency — for what feels like the *same* + relationship. + +2. **F7/F8 — the transition owner gate is declared but NOT ENFORCED on the live apply path.** The validator + codes an `owners ∪ authorizedSigners` gate; the **combiner — the authoritative apply path — does not + enforce it**, so committed state advances for any signer whose transition passes the **guard**. The + riverdale e2e observed exactly this (distinct keys; a non-owner's transition advanced the fiber). This is + a real **enforcement gap** (a likely security bug), settled by `TransitionOwnerGateDivergenceSuite`; §3 + has the corrected analysis and the fix. + +--- + +## 1. The authorization matrix (the deliverable) + +Consolidating the baseline into one decision table. The **"Net effective gate"** column is what governs +committed state — i.e. what the **combiner** (the live apply path) enforces; the **"Validator says"** column +is the *declared* gate, which for transitions is **not** the effective one (see the test note below). Every +row also requires the structural L1 checks (cid found, payload/size, sequence) from +`FiberValidator.L1Validator` / `FiberRules.L1`. + +| Action | Validator says (declared) | Combiner does (LIVE / effective) | Net effective gate | +|--------|---------------------------|----------------------------------|--------------------| +| **Create** a fiber | none | establishes `owners` from proofs | **anyone** (then owns) | +| **Transition** a *primary* fiber | `owners ∪ authorizedSigners` | **no owner check** — sequence + **guard** only | **guard-only** (≈ `Open`) ‡ | +| **Transition** a *spawned child* | child `owners` (no participants) | **no owner check** — sequence + **guard** only | **guard-only** (≈ `Open`) ‡ | +| **Archive** a fiber | **`owners` only** | sequence only | **owners declared, not combiner-enforced** ‡ | +| **Upgrade** a fiber | **`owners` only** + tighten/re-bind | sequence + binding/version + engine `UpgradeGate` (`migrationAuthority`) | binding/version + `UpgradeGate`; owner check is validator-only ‡ | +| **Registry op** (publish / setStatus / alias) | structural-only (rule #3) | **`RegistryEntry.owner` + lineage** | **owner+lineage (combiner)** — correctly placed | +| **Asset morphism** (Transfer/Compose/…) | structural-only (rule #3) | **policy + holder (R1)** | **policy+holder (combiner)** — correctly placed | +| **Fire** `_triggers` at a fiber | n/a (engine) | target's **`acceptedCallers`** (unset ⇒ anyone) | `acceptedCallers` (engine) | +| **Read** `machines.$id` | n/a (engine) | reader must **declare the dependency** | declared deps (engine) | + +‡ **The defect.** For transition/archive/upgrade the owner gate is coded in `validateSignedUpdate` but **not** +in the combiner — the inverse of the registry/asset placement, where the combiner is (correctly) the +authoritative gate. Because the combiner is what mutates committed state (and CLAUDE.md rule #2 names it +*"the authoritative deterministic gate"*), a check that lives only in `validateSignedUpdate` does **not** +govern committed state on the apply path. The transition row is therefore **effectively guard-only** — what +F7 reported. (Archive/upgrade are partially shielded by the engine `UpgradeGate` and by the fact that the +*only* meaningful archive/upgrade payload is owner-shaped, but the owner *signer* check has the same +placement smell and warrants the same audit.) + +> **Settled by test — `TransitionOwnerGateDivergenceSuite`** (`modules/shared-data/src/test/scala/xyz/kd5ujc/shared_data/TransitionOwnerGateDivergenceSuite.scala`, passing, no cluster). +> Alice creates a fiber → `owners = {Alice}`, **no participants**; Bob — a non-owner, non-participant — signs +> a `ping` transition (guard `true`). +> - `Validator.validateSignedUpdate(afterCreate, Bob-signed ping)` → **Invalid** (`:92`,`:100`) — the +> owner-or-participant gate *is* coded in the validator. +> - `Combiner.insert(afterCreate, Bob-signed ping)` → **APPLIES** — the fiber advances `s0 → s1` and the +> sequence increments (`:95`,`:101-102`). `FiberCombiner.processFiberEvent` does no owner check; it checks +> the sequence number then runs the engine guard. +> +> The pre-existing **`MultiPartyTransitionSigningSuite`** already encodes BOTH halves, and both pass: +> *"counterparty can sign … a fiber they didn't create"* asserts a non-owner, **non-participant** Bob's +> transition **APPLIES via `combiner.insert`** (`:23`,`:72` create has no `participants`, `:88` apply); while +> *"unauthorized third party CANNOT sign transitions"* asserts a non-owner, non-participant Charlie is +> **Invalid via `validator.validateSignedUpdate`** (`:197`,`:251`,`:253`). **Bob and Charlie are the same +> authorization class** — the suite is **self-contradictory in intent**, green only because each half probes +> a different layer. Reconciling it (which layer is the contract?) is part of the F7 fix (§3). + +--- + +## 2. F6 — reconcile the dependency asymmetry + +**The asymmetry, restated.** Fire-at is dependency-free and gated on the *receiver* (`acceptedCallers`); +look-at is dependency-mandatory and gated on the *caller* (must declare). They are two halves of the same +inter-fiber relationship, with opposite declaration ceremony, and the read half fails **silently** (a +`null`, not an error) when forgotten. + +**Why the read side requires a declared dependency (the real constraint — don't break it).** The +dependency set **bounds the context build**. `buildMachinesContext` projects *only* the declared +dependencies (`ContextProvider.scala:266-271`), and dynamic dependencies are capped by +`ExecutionLimits.maxActiveDependencies`. This bound is load-bearing: it makes the per-evaluation context +size a function of the fiber's **own declared** surface, not of the global fiber graph. If any fiber could +read any other fiber on demand, a single transition could force-load an arbitrary number of foreign records +into one evaluation context — an unbounded state-read / gas / committed-proof-size vector (the same DoS +class `asset-model.md` guards with bounded `usedNonces`). **So the dependency requirement on reads is a +feature; the problem is purely the *manual, silent, asymmetric* ceremony.** + +### Option (a) — AUTO-DECLARE a dependency from static `machines.$id` references — **RECOMMENDED** + +At create/upgrade, the engine statically scans the definition's guard/effect expressions for +`{"var":"machines..…"}` paths with a literal ``, and **adds those ids to the transition's +`dependencies`** (or surfaces them as an additive, engine-derived dependency set). The author writes the +read; the dependency is inferred. This removes the surprise entirely for the common case (a literal target) +while preserving the bound — the projected set is still finite and statically known, so context size stays +bounded. + +- **Pro:** kills F6 for static references; zero new runtime cost (a parse-time scan, reusing the + reserved-key/var walk already in `FiberRules.L1.extractMapKeys`-style traversal); the bound is preserved + because the auto-added set is exactly the literally-referenced ids. +- **Con / boundary:** a *computed* target id (`{"var":"event.target"}`) cannot be statically resolved, so + it still needs an explicit static `dependencies` entry **or** a runtime `_addDependency` (the existing + dynamic-dependency path; `ReservedKeys.ADD_DEPENDENCY`, gated by `DependencyPolicy`). That residue is + acceptable and honest: a dynamic read is exactly where an explicit, gas-charged, policy-bounded + declaration belongs. +- **Visibility:** the auto-declared set should be **observable** (e.g. surfaced in the `_policy`/dependency + projection or a definition-introspection endpoint) so an author can see what the scan inferred — see the + open question on immutability. + +**Verdict: adopt (a).** It is additive (only *adds* deps that the definition already references), needs no +signed-message change, and is caught entirely offline by the Proposal 01 validator (which already resolves +`var` paths) — the validator can both *warn* on an undeclared `machines.$id` read and *show* the +auto-declared set. + +### Option (b) — make `machines.$id` dependency-free by projecting any referenced fiber on demand — **DECLINE** + +Resolve `machines.` lazily against `CalculatedState` whenever a guard/effect dereferences it, with no +declaration. + +- **Fatal con:** removes the context bound. Context-build cost becomes a function of how many foreign + fibers an expression dereferences, which an adversary controls — a committed-path DoS and a gas-accounting + hole (the read happens *inside* metered evaluation, but the *projection* cost in `ContextProvider` is paid + before metering). It also makes the committed-state read-set of a transition non-obvious, complicating any + future KPN/sharded-evaluation story (`jlvm-engine-foundations.md` §3.3 — independent fibers can run in + parallel **only** if their read-sets are declared). +- **Verdict: decline.** Keep dependencies; auto-declare them (a). If a bounded form of (b) is ever wanted, + it must come with an explicit per-evaluation **read-fanout cap** charged as gas — but (a) already solves + the ergonomic complaint without that complexity. + +**Symmetry note.** (a) leaves a pleasing symmetry: *fire-at* is gated on the **receiver** (`acceptedCallers`), +*look-at* is gated on the **caller** (declared deps, now auto-inferred). Both directions are explicit and +bounded; neither requires the author to hand-maintain a list for the static case. + +--- + +## 3. F7 — the transition-authorization enforcement gap, and the fix + +### 3.1 The finding is REAL — it is an enforcement gap, not a perception artifact + +F7 as recorded — *"a transition is NOT owner-gated; any signer can transition any fiber; the guard is the +only gate"* — **is what the live apply path actually does.** The owner gate is *coded* (in the validator), +*declared*, and *not enforced*: + +``` +DECLARED (validator) EFFECTIVE (combiner — the apply path) +Validator.validateSignedUpdate (:166) Combiner.insert (Combiner.scala:70) + → CombinedValidator.processEvent (:163) → FiberCombiner.processFiberEvent (:101-144) + → updateSignedByOwnerOrParticipant sequence-number check + engine.process(guard) + owners ∪ authorizedSigners (FiberRules:308) — NO owner / authorizedSigners check at all + → Invalid for a non-owner → APPLIES a non-owner's transition +``` + +**Why "declared but not effective":** the combiner is the layer that mutates committed state, and CLAUDE.md +rule #2 names it *"the authoritative deterministic gate."* The riverdale e2e (and +`TransitionOwnerGateDivergenceSuite`, §1) show a non-owner's transition **advancing the fiber** — so +whatever `validateSignedUpdate` returns, it is **not** the gate that governs committed state for transitions. +The owner check is in the *wrong layer to bind*: for registry/asset ops the chain (correctly, by rule #3) +puts the owner/lineage gate **in the combiner**; for transitions it put it **only in the validator** — the +inverse — so it does not bind. + +**Retraction (important).** An earlier draft of this RFC explained F7 as *"the gate is real but invisible in +a single-key harness."* **That is false and is withdrawn.** The riverdale e2e used **distinct** keys (Alice +creates → `owners = {Alice}`; Bob signs the transition) and Bob's transition **still applied**. The cause is +not key reuse; it is that the combiner enforces no owner gate. Git history is consistent with this: #161 +("multi-party fiber signing", `9a7fe81`) relaxed the *validator* from `updateSignedByOwners` to +`updateSignedByOwnerOrParticipant`, but **never added an owner check to the combiner** — so the combiner has +been guard-only the entire time. + +**Net:** today, **every** transition is effectively `Open` (guard-only) on the live path, regardless of +owners/participants. For an app that *wanted* owner gating (the common case — a contract whose state only +its owner should advance), this is a **silent authorization bypass**: a likely **security bug**. For an app +that *wanted* a public state machine, it is accidentally correct. The model is currently **looser than +anyone declared**, which is the opposite of safe defaults. + +### 3.2 What the right model is — make the posture explicit *and actually enforced* + +Two things are simultaneously true and must both be fixed: + +1. **There is a legitimate "public state machine" posture** — a fiber whose access control lives **entirely + in the guard** (a prediction market anyone may resolve-attempt, a public-good counter, a permissionless + queue). That posture is *useful* and should be **expressible on purpose**. +2. **The current default IS that posture, by accident, for everyone** — including apps that wanted + owner-only transitions and have no way to get them on the apply path. That is the bug. + +So the fix is **not** merely "add an `Open` mode." It is: **make signer-authorization actually enforced on +the apply path (the combiner), and make the posture an explicit, opt-in choice** — so an app *declares* +whether it is owner-gated, participant-gated, or public, and the chain *enforces* that declaration where it +counts. + +### 3.3 The fix — enforce in the combiner, then layer an opt-in `transitionPolicy` dial + +**Step 1 (the bug fix): move signer-authorization into the combiner** as a graceful `CombineRejected → +RejectionReceipt` (the same authoritative-gate pattern registry/asset ops already use, `#154`). This is +what makes *any* transition signer gate bind on committed state. `FiberCombiner.processFiberEvent` +(`:101-144`) already resolves the fiber record and checks the sequence number; the owner/participant check +slots in right there, before `orchestrator.process`, against the **verified** signer addresses. The +validator may keep its check as a cheap fail-fast *preview* (structural-ish, owners are stable), but the +combiner becomes the binding gate. + +**Step 2 (make the posture explicit): add an opt-in `transitionPolicy` dial** to `FiberPolicy.Constrained` +(`modules/.../schema/fiber/FiberPolicy.scala:264-293`), `Option`/omit-safe like every other dial, enforced +**in the combiner** alongside Step 1: + +```scala +// new sealed ADT, modeled on the existing UpgradePolicy / DependencyMode tighten-lattice ADTs +sealed trait TransitionPolicy { def rank: Int } +object TransitionPolicy { + case object Open extends TransitionPolicy { val rank = 0 } // any signer; guard is the sole gate (LOOSEST) + case object OwnersOrParticipants extends TransitionPolicy { val rank = 1 } // owners ∪ authorizedSigners + case object Owners extends TransitionPolicy { val rank = 2 } // strict owners-only (TIGHTEST) +} +// FiberPolicy.Constrained += transitionPolicy: Option[TransitionPolicy] = None +``` + +`Allowlist` is **not** a separate mode: the existing `authorizedSigners`/`participants` set *is* the +allowlist, so `OwnersOrParticipants` already covers it. The dial joins the **tighten-only lattice** +(`FiberPolicy.tightens`, `FiberPolicy.scala:411-433`) as a `rankUp` dial — a migration may only move toward +**stricter** (`Open → OwnersOrParticipants → Owners`), never loosen — so a fiber can never launder itself +from `Owners` down to `Open`. + +### 3.4 The back-compat trap — what the absent-dial default must be is a DELIBERATE decision (lead open question) + +This is the crux, and it is genuinely hard because **the live behavior and the declared behavior disagree**, +so "back-compat" is ambiguous: + +- **Back-compat with the LIVE behavior** ⇒ absent-dial default = **`Open`** (guard-only). Step 1 would then + *not* change any existing fiber's effective gate (they are all guard-only today); apps opt **up** to + `OwnersOrParticipants`/`Owners`. **Safe to ship (no behavior change), but it blesses the bypass as the + default** — every existing owner-gated-by-intent app stays unprotected until it opts in. +- **Back-compat with the DECLARED/intended behavior** ⇒ absent-dial default = **`OwnersOrParticipants`**. + Step 1 then **starts enforcing** the gate the validator always claimed — which is a **tightening** that + **will break** any app relying on the current guard-only reality, including the deliberate + *"counterparty can sign a fiber they didn't create"* pattern that `MultiPartyTransitionSigningSuite` + encodes (Bob is neither owner nor participant, yet is *supposed* to be able to sign). This restores the + intended security posture but is a **breaking change** for live apps. + +**These cannot both be satisfied by a default; it is a policy call, not a code detail.** It is the **lead +open question** of this RFC (§6 Q1). A reasonable path: default **`OwnersOrParticipants`** (restore the +intended gate — security-first), but (a) treat it as a **breaking change** gated behind an engine-version +bump and a migration window, and (b) **first reconcile `MultiPartyTransitionSigningSuite`** — decide whether +"counterparty signing" is a supported feature (then it needs an *explicit* mechanism: the counterparty must +be a declared `participant`/`authorizedSigner`, or the fiber must declare `transitionPolicy = Open`) or an +accident of the bypass (then the test's "counterparty can sign" half is asserting the bug and must change). +**The self-contradictory suite must be resolved as part of this fix, not around it.** + +### 3.5 Rule #3 safety of combiner enforcement + +Enforcing signer-auth + `transitionPolicy` in the combiner is **rule-#3-safe**: it reads only the fiber's +own hash-pinned `definition.policy` dial and the **stable** `owners`/`authorizedSigners` record fields — no +`lineageOf`/`resolve`/`versionAppendable`, no registry/asset lineage. It is therefore **not** the TOCTOU +block-poisoning hazard rule #3 guards against; it is exactly the graceful, deterministic combine-reject the +rule *prescribes* for stateful gates. A bonus: moving the check off `validateSignedUpdate` also removes the +latent `Create`+`Transition`-batched-in-one-block edge (the acceptance-time owner read sees `None` for a +fiber created earlier in the same block, because the validator sees pre-block state; the combiner sees +intra-batch state and resolves it correctly). + +--- + +## 4. F8 — spawned-child owners ergonomics + +### 4.1 What the code does (confirmed) — and what F8's "rejection" actually was + +A spawned child is an ordinary `StateMachineFiberRecord`, so it goes through the **same divergent** +authorization as any transition (§1/§3): the validator declares an `owners`-only gate, the combiner enforces +**none**. Its only structural differences are **where `owners` come from** and that it has **no +`authorizedSigners`**: + +```scala +// modules/shared-data/.../fiber/spawning/SpawnProcessor.scala:132-146 +childFiber = Records.StateMachineFiberRecord( + …, + owners = spawn.resolvedOwners, // ← from the directive; authorizedSigners NOT set ⇒ Set.empty (Records.scala:47) + …) +``` + +```scala +// modules/shared-data/.../fiber/spawning/SpawnValidator.scala:230-237 +directive.ownersExpr match { + case None => Validated.validNel(parent.owners) // inherit the parent's owners + case Some(expr) => …evaluate expr → Set[Address]… // e.g. {"var":"event.auctionOwners"} +} +``` + +`SpawnDirective.ownersExpr: Option[JsonLogicExpression]` (`modules/.../schema/fiber/SpawnDirective.scala:15`). +A spawn that sets `ownersExpr = {"var":"event.auctionOwners"}` gives the child `owners = auctionOwners`, +with no participants — so the child's **declared** (validator) gate is `owners` only, and an *omitted* +`ownersExpr` inherits the parent's owners. + +**But F8's reported mechanism needs re-examination in light of §3.** F8 recorded that *"a bidder must be in +`event.auctionOwners` or `place_bid` is ML0-rejected"* and attributed the rejection to the +`owners ∪ authorizedSigners` gate. Under the §1 test finding, the **combiner does not enforce that gate**, +so on the live apply path the child's `owners` do **not** by themselves block a non-owner bidder. The +bidder's rejection in the riverdale run was therefore **one of**: + +- **(most likely) the `place_bid` GUARD** — the auction app's own guard checks the signer (e.g. + `proofs[].address` ∈ `state.auctionOwners`), which *is* the correct place for app-level access control and + *would* reject the bidder regardless of the owner gate; or +- **a `validateSignedUpdate` rejection that bound in that run** — if so, it is the **same enforcement gap** + from the other side (a validator owner-gate that may or may not bind on the apply path), making F8 a + **second witness** of the §3 divergence rather than independent confirmation of an owner gate. + +Either way, **the durable F8 takeaway stands but is reframed**: the child's `owners` are set at spawn time +from an expression the author may not have reasoned about as "the auth list," AND — because the framework +owner gate does not bind in the combiner — *the only reliably-enforced access control on a child's +transitions today is the guard*. So an auction that wants bidder-restriction must put that check **in the +`place_bid` guard**, not rely on the child's `owners`. This is itself a strong argument for §3 (make the +posture explicit and actually enforced). + +(The parent's `FiberPolicy.spawnOwnerPolicy` dial — `Explicit | SubsetOfParent | InheritParent`, +`FiberPolicy.scala:39-47`, applied in `SpawnValidator.applySpawnOwnerPolicy:210-228` — constrains the +resolved owner *set* but, like the rest of the owner machinery, only governs the validator-declared gate.) + +### 4.2 Proposals + +- **Put bidder-restriction in the GUARD (the only reliably-enforced gate today).** Until §3 lands, an + auction that wants to restrict who may `place_bid` must check the signer **inside the `place_bid` guard** + (`proofs[].address` ∈ `state.auctionOwners`), because the child's `owners` do not bind in the combiner + (§4.1). The SDK template (Proposal 00) and the offline validator (Proposal 01) should make this the + default pattern and **warn** when a child relies on `owners` for access control without a corresponding + guard check. +- **SDK helper + offline validator support.** A `spawn({ owners })` builder in `@ottochain/sdk` + (Proposal 00) that makes `ownersExpr` an explicit, named argument, plus a Proposal-01 rule: when a + definition `_spawn`s a child whose transitions are *intended* to be owner/participant-restricted, **warn** + that today only a guard enforces it — caught offline, before the author ships an auction that silently + accepts bids it meant to forbid (the live failure mode under §4.1, *worse* than an ML0 rejection because + it is silent). +- **Interaction with §3 `transitionPolicy` (the real fix).** Once §3 makes the gate combiner-enforced, a + spawned auction declares its posture explicitly: `transitionPolicy = Open` for a public auction (anyone + may attempt `place_bid`; the guard alone decides), or `Owners`/`OwnersOrParticipants` for a closed one + (and then the child's `owners` actually bind). The child sets the dial in its own (hash-pinned) + `definition.policy`, so no new spawn field is needed — and the "is the child public or closed?" question + becomes an explicit, enforced, author-visible choice instead of an accident of `ownersExpr` + a + non-binding gate. + +--- + +## 5. Safety / compatibility + +F6 and F8's *tooling* changes are additive and back-compatible. The F7 fix (§3) is **deliberately NOT +fully additive** — it closes a security gap, and closing it changes effective behavior for at least one +posture. All of it respects the three `signing-canonical-and-validation.md` invariants: + +- **Rule #1 (signed canonical).** The only new signed-message surface is `FiberPolicy.transitionPolicy: + Option[TransitionPolicy] = None`, an `Option`/omit-safe dial that encodes inside the existing + `Constrained` dials object and is stripped by `dropNulls` when absent — exactly like the existing dials, + so a pre-dial definition is **hash-identical** to one that omits it. Add a case to + `PublishVersionSigningCanonicalSuite`. (The *meaning* of an absent dial — §3.4 — is a runtime-enforcement + choice, independent of the bytes: absence encodes identically regardless of which default the engine + assigns.) F6 auto-declared dependencies are an **engine-derived** projection, not a new signed field; the + scan must augment the *runtime* dependency set only and never mutate the signed `Transition.dependencies`. +- **Rule #2 (structural gate vs stateful combiner).** Both the signer-auth fix (Step 1) and `transitionPolicy` + (Step 2) are enforced as **graceful `CombineRejected`** in the combiner — the *authoritative* gate — never + as a block-acceptance `Invalid`. This is the **correct placement** the transition gate lacks today, and it + brings transitions in line with how registry/asset owner gates already work. Block acceptance keeps its + structural L1 checks (cid, payload, sequence) unchanged. +- **Rule #3 (no lineage at acceptance).** The combiner enforcement reads only the fiber's own hash-pinned + dial and the stable `owners`/`authorizedSigners` record fields — no registry/asset **lineage** — so it is + outside the TOCTOU hazard class (§3.5). Registry/asset owner gates remain combine-only, unchanged. +- **The absent-dial default is the LEAD open question, NOT settled here (§3.4 / §6 Q1).** Because today's + *live* behavior (guard-only) and *declared* behavior (`owners ∪ authorizedSigners`) disagree, "back-compat" + is ambiguous: a `Open` default preserves live behavior but blesses the bypass; an `OwnersOrParticipants` + default restores the intended gate but is a breaking change. This must be decided deliberately (with an + engine-version bump + migration window if tightening) and the self-contradictory + `MultiPartyTransitionSigningSuite` reconciled — it is **not** an additive, ship-anytime change. +- **Tighten-only preserved.** `transitionPolicy` joins `FiberPolicy.tightens` as a `rankUp` dial, so a + *migration* can only make transitions *stricter* (`Open → OwnersOrParticipants → Owners`), never launder a + fiber from `Owners` down to `Open` — the same monotone trust guarantee the other dials give. + +--- + +## 6. Alternatives, effort/risk, open questions + +### Alternatives considered + +- **F6 (b) on-demand projection** — declined (§2): removes the context bound (DoS/gas/sharding). +- **F7 keep status quo, document only** — declined: F7 is a real enforcement gap (a likely security + bypass), not a perception issue. Documenting it without fixing it leaves every owner-gated-by-intent fiber + unprotected on the apply path. +- **F7 fix it by enforcing only in `validateSignedUpdate`** (make the validator's verdict actually bind + before combine) — declined: it is the wrong layer (rule #2 names the combiner authoritative), it carries + the create+transition-same-block hazard (§3.5), and an acceptance-time `Invalid` is a silent block-drop + rather than a `RejectionReceipt`. Enforce in the combiner. +- **F8 a new `SpawnDirective.participantsExpr` field** — declined: a new signed-message field for what the + child's own `transitionPolicy` (or a guard check) already expresses; avoid widening the signed spawn + surface. + +### Effort / risk + +| Change | Effort | Risk | Notes | +|--------|--------|------|-------| +| **Docs** — authoring-gotchas page + inline comments at `buildMachinesContext`, `policyShortCircuit`, `FiberCombiner.processFiberEvent` (the missing owner check), `SpawnDirective.ownersExpr`; land the corrected, test-cited F7 matrix | XS | None | Land first (program P0); records the **enforcement gap** so no one relies on the validator-only gate. | +| **F6 (a)** auto-declare static `machines.$id` deps (+ validator warn/show) | S–M | Low | Parse-time scan; additive to the **runtime** dep set; preserves the bound; offline-validatable. | +| **F8** SDK `spawn({owners})` + validator "owners don't bind — check in guard" warning | S | None (off-chain) | Off-chain; consumes Proposal 01 var-resolution. | +| **F7 Step 1** — enforce signer-auth in `FiberCombiner.processFiberEvent` (graceful `CombineRejected`) | S–M | **High** | **The security fix.** Changes effective behavior (see Q1). MUST settle the default (Q1) + reconcile `MultiPartyTransitionSigningSuite` + engine-version bump + a riverdale-economy e2e lane. Not additive. | +| **F7 Step 2** — opt-in `transitionPolicy` dial (ADT + `tightens` + combiner check) | M | Med | Builds on Step 1; touches signed canonical (one `Option` dial); golden round-trip + `PublishVersionSigningCanonicalSuite`. | + +### Open questions + +1. **(LEAD) What is the absent-`transitionPolicy` default, given live ≠ declared behavior?** `Open` + (preserve today's live guard-only behavior; non-breaking but blesses the bypass as the default) vs + `OwnersOrParticipants` (restore the intended/declared gate; security-first but a **breaking change** for + apps relying on the current reality, including the deliberate "counterparty can sign" pattern). This is a + policy decision with security and compatibility weight, **not settled by this RFC** (§3.4). It is coupled + to: **reconciling `MultiPartyTransitionSigningSuite`** — is "a non-owner/non-participant counterparty can + sign" a *supported feature* (then it needs an explicit mechanism — declared `participant` or + `transitionPolicy = Open`) or an artifact of the bypass (then that test half asserts the bug)? Recommend + `OwnersOrParticipants` default behind an engine-version bump + migration window, with counterparty + signing made explicit — but flag for the maintainers to decide. +2. **Does `validateSignedUpdate` actually bind at all in production today?** The combiner clearly applies a + non-owner transition (test). Whether the framework *also* drops the update on a `validateSignedUpdate` + `Invalid` before/around combine — and under which deployment topology (the riverdale local cluster had + drifted) — should be confirmed by tracing the ML0 apply path, since it determines whether the gap is + "validator gate is dead" or "validator and combiner disagree and the combiner wins on committed state." + Either way the §3 fix (enforce in the combiner) is correct; this only affects how we *describe* the + current behavior. +3. **Auto-declared deps — visible and/or immutable?** Should the F6 (a) scan's inferred dependency set be + (i) surfaced read-only in the dependency/`_policy` projection, and (ii) runtime-only (never written into + the signed `Transition.dependencies`)? Recommendation: **yes to both**; confirm no canonical-divergence + path. +4. **`transitionPolicy` vs cross-fiber triggers — what is "the signer" of a cascaded transition?** A + `_triggers`-driven transition has `caller = Some(sourceFiberId)` and no fresh wallet proofs on the + cascaded leg. Under `Open` it is moot. Under `OwnersOrParticipants`/`Owners`, define whether the gate + reads the originating wallet's proofs (already threaded), the source fiber's id, or both. Today + `acceptedCallers` governs the *fiber* caller and `proofs` the *wallet* caller, deliberately orthogonal + (`policyShortCircuit` comment, `FiberEvaluator.scala:124-127`). Recommendation: `transitionPolicy` gates + the **wallet/owner** axis only; `acceptedCallers` remains the fiber-caller axis; they compose. +5. **F8 default owners.** Should an omitted `ownersExpr` inherit the parent's owners (today) or default to + the participants seeded into the child's `initialData`? Settle in the SDK helper (Proposal 00) before any + chain-side default change. diff --git a/docs/proposals/fiber-ergonomics/README.md b/docs/proposals/fiber-ergonomics/README.md new file mode 100644 index 00000000..0932d3ce --- /dev/null +++ b/docs/proposals/fiber-ergonomics/README.md @@ -0,0 +1,76 @@ +# Fiber & Asset Authoring Ergonomics — Improvement Program + +**Status:** proposal set (RFC) · **Branch:** `feat/fiber-ergonomics-proposals` +**Origin:** friction discovered authoring the `riverdale-economy` e2e (6 fibers, 2 versioned packages, +real asset custody, cross-fiber triggers, spawned auction, wallet morphisms — all hand-written JSON-Logic). + +## North star + +App authors should **consume vetted templates from the SDK and a genesis-loaded canonical std-lib** +instead of hand-rolling JSON-Logic, and should **see their mistakes before the cluster does** via a +dry-run validator. Most of the friction below is *not* "the model is wrong" — it's "the model is +invisible until combine time." Two high-leverage moves fix the bulk: + +1. **SDK template library + genesis std-lib** — canonical builders that emit correct forms; standard + packages (fungible/NFT policy, versioned-machine skeleton, guarded-transfer effect, spawn directive, + migration) registered at genesis so apps reference rather than redefine. → [`00-sdk-stdlib-and-templates.md`](./00-sdk-stdlib-and-templates.md) (the SDK-repo **handoff** doc). +2. **Definition dry-run validator** — resolve every `var` path, validate reserved `_`-keys, check schema + conformance + reachability, all offline. → [`01-authoring-safety.md`](./01-authoring-safety.md). + +The remaining proposals are targeted model-clarity / canonicalization changes. + +## Findings catalog (what hurt, and where it's addressed) + +| # | Friction (from the riverdale-economy build) | Severity | Addressed by | +|---|---|---|---| +| F1 | **Asset custody is bifurcated**: a fiber-held asset CANNOT take `ApplyMorphism` (R1 `requireWalletHolder`) — fiber value moves only via the `_transferAsset` effect, whole-instance. Forced a redesign (separate pre-minted instances instead of fractionalizing a loan). | High | [02](./02-asset-effect-ergonomics.md), [00](./00-sdk-stdlib-and-templates.md) | +| F2 | **`_transferAsset` recipient is a BARE STRING** (UUID→Fiber, DAG-addr→Wallet via `parseRecipient`) — shaped *unlike* the `{Fiber:{fiberId}}`/`{Wallet:{address}}` `AssetHolder` it becomes everywhere else. | Med | [02](./02-asset-effect-ergonomics.md) | +| F3 | **"Who holds it" decides "whose effect transfers it"** — the GOODS→consumer leg had to live on the *retailer's* `process_sale` effect (it holds the GOODS), not the consumer's `buy`. Custody-aware effect placement. | Med | [02](./02-asset-effect-ergonomics.md), [00](./00-sdk-stdlib-and-templates.md) | +| F4 | **Effect object is dual-purpose**: `_`-prefixed keys are reserved directives (stripped by `StateMerger`), everything else merges into state — so a typo'd `_trigger`/`_transfer` silently becomes a *state field* with no error. | Med | [01](./01-authoring-safety.md) | +| F5 | **No state-field defaults**: `{"+":[{"var":"state.x"},…]}` on an unseeded `x` reads null and misbehaves; every accumulator must be seeded in `initialData` (or use the clunkier `{"var":["x",0]}`). | Med | [01](./01-authoring-safety.md) | +| F6 | **Cross-fiber asymmetry**: a `_triggers` needs NO declared dependency (only `FiberPolicy.acceptedCallers`), but a `machines.$id.state` READ requires declaring the dependency. Opposite ceremony for "fire at" vs "look at". | Med | [03](./03-cross-fiber-and-authorization.md) | +| F7 | **Transition owner-gate DIVERGES by code path — a likely authorization GAP** (test-confirmed, `TransitionOwnerGateDivergenceSuite`): `validateSignedUpdate` ENFORCES `owners ∪ authorizedSigners` (rejects a non-owner), but the **combiner does NOT** — it applies a non-owner's transition (the guard is its only gate). The live ML0 path follows the combiner, so **in production transitions are effectively guard-only-gated** (our riverdale e2e, distinct keys, saw bob transition alice's fiber). The pre-existing `MultiPartyTransitionSigningSuite` encodes both halves and is self-contradictory. | **High (security)** | [03](./03-cross-fiber-and-authorization.md) | +| F8 | **Spawned-child transition auth** is the exception: a child's transitions are gated by `owners ∪ authorizedSigners`, so a bidder must be listed in `event.auctionOwners` or `place_bid` is ML0-rejected. | Low-Med | [03](./03-cross-fiber-and-authorization.md) | +| F9 | **Versioning foot-guns**: verified binding needs byte-identical publish/create definition files; lineage is monotonic append-only; the migration context root is the *bare* prior state (`{"var":""}`), unlike effects' `state.x`. | Low | [00](./00-sdk-stdlib-and-templates.md), [01](./01-authoring-safety.md) | +| F10 | **Root cause — hand-written JSON-Logic, no safety net**: deeply nested `{"var":…}`/`{"+":[…]}`, no types, no autocomplete, mistakes surface only at combine. | High | [00](./00-sdk-stdlib-and-templates.md), [01](./01-authoring-safety.md) | + +## Proposal index + +- **[00 — SDK std-lib + templates](./00-sdk-stdlib-and-templates.md)** — *the SDK-repo handoff doc.* Canonical builders/templates in `@ottochain/sdk`; a genesis-loaded std-lib of vetted packages; apps consume templates instead of hand-rolling. Executable spec for an agent working in `~/repos/ottochain-sdk`. +- **[01 — Authoring safety](./01-authoring-safety.md)** — offline definition validator (var-path resolution, reserved-key validation, reachability, conformance) + declared state-shape with defaults. (F4, F5, F9, F10) +- **[02 — Asset-effect ergonomics](./02-asset-effect-ergonomics.md)** — canonicalize the `_transferAsset` recipient (accept the `AssetHolder` object form), document + (where safe) soften the fiber/wallet morphism boundary, the custody-aware transfer pattern. (F1, F2, F3) +- **[03 — Cross-fiber & authorization model](./03-cross-fiber-and-authorization.md)** — reconcile the trigger-vs-read dependency asymmetry, clarify (and optionally opt-in-gate) transition authorization, smooth spawned-child owners. (F6, F7, F8) + +## Safe-improvement roadmap (the task list) + +Ordered by **risk-ascending** and **leverage-descending**. The hard guardrail throughout: **nothing may +shift the signed-message canonical** (CLAUDE.md rule #1 — a field that changes shape/default re-encodes to +a different `JCS(dropNulls)` and breaks `InvalidSignature`), and **registry-lineage checks stay +combine-only** (rule #3). Every change is **additive-first**: hand-rolled JSON keeps working. + +| Phase | Work | Risk | Why this order | +|---|---|---|---| +| **P0** | **Documentation** — land this proposal set; add a "fiber-definition authoring gotchas" page + inline doc-comments at the surprising sites (`EffectExtractor.parseRecipient`, `requireWalletHolder`, the `machines.$id` dep requirement, spawned-child owners). | None | Captures the knowledge immediately; zero code risk; informs everything else. | +| **P1** | **Dry-run validator** (Proposal 01) — a pure, offline `validateDefinition(def, schema?)` in metakit/SDK that resolves var paths, flags unknown `_`-keys, checks state-field reads vs `initialData`/shape, verifies reachability. Wire it into the e2e runner + SDK. | Low (additive tooling, no chain change) | Highest leverage / lowest risk — makes every later change *and* every app author safer; catches F4/F5/F10 before the cluster. | +| **P2** | **SDK template library** (Proposal 00, SDK side) — typed builders that emit canonical fiber defs, asset policies, effects (`transferAsset()`, `triggers()`, `spawn()`), migrations. Apps opt in. | Low (additive, off-chain) | Stops hand-rolling for new apps without touching the chain; consumes the validator from P1. | +| **P3** | **Genesis std-lib** (Proposal 00, chain side) — a vetted set of canonical registry packages (fungible/NFT policy presets, machine skeletons) published at genesis; the SDK references them. Needs versioning + ownership/governance + determinism (genesis must be reproducible). | Med (genesis surface + registry; builds on `genesis-and-engine-versioning.md`) | Depends on the template forms (P2) being settled; genesis is consensus-critical so it lands after the off-chain forms stabilize. | +| **P4** | **Targeted model changes** — (a) accept the `AssetHolder` object form in `_transferAsset` *alongside* the bare string (Proposal 02); (b) declared state-shape **defaults** so accumulators auto-init (Proposal 01); (c) make `machines.$id` reads dependency-free or auto-declare (Proposal 03); (d) optional opt-in transition owner-gating via `FiberPolicy` (Proposal 03). | Med-High (touches the combiner/evaluator + canonical) | Each is additive + backward-compatible by construction, but touches consensus code — lands last, behind the validator + golden canonical tests, one at a time. | + +**Cross-cutting safety rails:** additive-only (old forms keep parsing); the signed canonical is frozen +(new optional fields are `Option`/omit-safe, guarded by `PublishVersionSigningCanonicalSuite`); registry +lineage checks remain combine-only; every chain change ships with golden round-trip + a riverdale-economy +e2e lane run; genesis std-lib changes are reproducible + versioned. + +## Relationship to existing RFCs (build on, don't duplicate) + +- `genesis-and-engine-versioning.md` — the genesis + engine-version surface the std-lib (P3) plugs into. +- `jlvm-engine-foundations.md` — the evaluator/effect model these ergonomics sit on. +- `strong-typing-and-conformance.md` + `schema-architecture.md` — the conformance/typing layer the validator (P1) and state-shape defaults extend. +- `asset-model.md` — the asset/morphism model Proposal 02 refines (R1, `_transferAsset`, custody). + +## Execution + +- **00** is an **executable handoff** for an agent in `~/repos/ottochain-sdk` (+ the genesis side in + ottochain). **01–03** are **RFCs to review** before implementing the chain-touching parts. Follow the + P0→P4 ordering; the validator (P1) and SDK templates (P2) are the safe, high-value first steps that need + no consensus change. diff --git a/modules/shared-data/src/test/scala/xyz/kd5ujc/shared_data/TransitionOwnerGateDivergenceSuite.scala b/modules/shared-data/src/test/scala/xyz/kd5ujc/shared_data/TransitionOwnerGateDivergenceSuite.scala new file mode 100644 index 00000000..491f9823 --- /dev/null +++ b/modules/shared-data/src/test/scala/xyz/kd5ujc/shared_data/TransitionOwnerGateDivergenceSuite.scala @@ -0,0 +1,105 @@ +package xyz.kd5ujc.shared_data + +import cats.effect.IO +import cats.effect.std.UUIDGen +import cats.syntax.all._ + +import io.constellationnetwork.currency.dataApplication.{DataState, L0NodeContext, L1NodeContext} +import io.constellationnetwork.metagraph_sdk.json_logic._ +import io.constellationnetwork.security.SecurityProvider +import io.constellationnetwork.security.signature.Signed + +import xyz.kd5ujc.schema.fiber._ +import xyz.kd5ujc.schema.{CalculatedState, OnChain, Records, Updates} +import xyz.kd5ujc.shared_data.lifecycle.{Combiner, Validator} +import xyz.kd5ujc.shared_test.Participant._ +import xyz.kd5ujc.shared_test.TestFixture + +import io.circe.parser._ +import weaver.SimpleIOSuite + +/** + * Settles riverdale-economy ergonomics finding F7: is a primary state-machine TRANSITION owner-gated? + * + * The answer is DIVERGENT BY CODE PATH — which is the bug/smell this test pins: + * - `Validator.validateSignedUpdate` ENFORCES `owners ∪ authorizedSigners` + * (FiberValidator.processEvent -> FiberRules.L0.updateSignedByOwnerOrParticipant) -> a non-owner is Invalid. + * - `Combiner.insert` (the live apply path) does NOT check owners -> a non-owner's transition APPLIES; + * the transition GUARD is the only gate. + * + * The riverdale-economy e2e observed the COMBINER behavior (a bob-signed transition on an alice-owned fiber + * ADVANCED the fiber), so in production transitions are effectively guard-only-gated: the validator's owner + * gate is not reached/enforced before the combiner applies the transition. The pre-existing + * `MultiPartyTransitionSigningSuite` already encodes BOTH halves separately ("counterparty can sign … they + * didn't create" via the combiner; "unauthorized third party CANNOT sign" via the validator); this test + * asserts both in ONE case so the divergence is explicit and regression-guarded. See + * docs/proposals/fiber-ergonomics/03-cross-fiber-and-authorization.md. + */ +object TransitionOwnerGateDivergenceSuite extends SimpleIOSuite { + + test( + "F7: the validator REJECTS a non-owner transition, but the combiner APPLIES it (gate not enforced on the apply path)" + ) { + TestFixture.resource(Set(Alice, Bob)).use { fixture => + implicit val s: SecurityProvider[IO] = fixture.securityProvider + implicit val l0ctx: L0NodeContext[IO] = fixture.l0Context + implicit val l1ctx: L1NodeContext[IO] = fixture.l1Context + for { + combiner <- Combiner.make[IO]().pure[IO] + validator <- Validator.make[IO] + fiberId <- UUIDGen.randomUUID[IO] + + defJson = + """ + { + "states": { + "s0": { "id": "s0", "isFinal": false }, + "s1": { "id": "s1", "isFinal": false } + }, + "initialState": "s0", + "transitions": [ + { + "from": "s0", + "to": "s1", + "eventName": "ping", + "guard": true, + "effect": { "status": "s1" }, + "dependencies": [] + } + ] + } + """ + machineDef <- IO.fromEither(decode[StateMachineDefinition](defJson)) + + // Alice creates the fiber -> owners = {Alice}, NO participants declared (authorizedSigners empty). + create = Updates.CreateStateMachine(fiberId, machineDef, MapValue(Map.empty[String, JsonLogicValue])) + createProof <- fixture.registry.generateProofs(create, Set(Alice)) + afterCreate <- combiner.insert( + DataState(OnChain.genesis, CalculatedState.genesis), + Signed(create, createProof) + ) + + // Bob — NOT an owner, NOT a participant — signs a transition. + ping = Updates.TransitionStateMachine( + fiberId, + "ping", + MapValue(Map.empty[String, JsonLogicValue]), + FiberOrdinal.MinValue + ) + bobProof <- fixture.registry.generateProofs(ping, Set(Bob)) + + // (a) the VALIDATOR rejects Bob (the owner gate IS enforced in validateSignedUpdate) + validatorResult <- validator.validateSignedUpdate(afterCreate, Signed(ping, bobProof)) + + // (b) but the COMBINER APPLIES Bob's transition (no owner check on the apply path — guard is the gate) + afterPing <- combiner.insert(afterCreate, Signed(ping, bobProof)) + applied = afterPing.calculated.stateMachines + .get(fiberId) + .collect { case r: Records.StateMachineFiberRecord => r } + + } yield expect(validatorResult.isInvalid) and // validator WOULD reject a non-owner + expect(applied.map(_.currentState).contains(StateId("s1"))) and // ...but the combiner applied it anyway + expect(applied.exists(_.sequenceNumber > FiberOrdinal.MinValue)) + } + } +}