diff --git a/.changeset/milab-6648-block-kinds-and-templates.md b/.changeset/milab-6648-block-kinds-and-templates.md new file mode 100644 index 0000000000..969a17571b --- /dev/null +++ b/.changeset/milab-6648-block-kinds-and-templates.md @@ -0,0 +1,93 @@ +--- +"@platforma-sdk/block-kind": minor +"@milaboratories/pl-model-common": minor +"@milaboratories/pl-model-middle-layer": minor +"@milaboratories/pl-middle-layer": minor +"@platforma-sdk/block-tools": minor +"@milaboratories/ts-builder": minor +"@platforma-sdk/model": minor +--- + +Block kinds and project templates. + +A **block kind** is a separately-versioned npm package declaring the typed init-params +contract a block is created from; many block versions implement one kind version. On top +of kinds sits the **template engine**: a project exports to `template-v1` YAML, and a +template — exported or hand-authored — applies into a fresh project. + +**New package `@platforma-sdk/block-kind`.** `defineBlockKind({ name, +version, parseInitializationParams })` returns a frozen `CompiledBlockKind`. Source `name` / +`version` from the kind's own `package.json` so the on-wire `{name}@{version}` cannot +drift from what npm publishes. + +```ts +// /kind/src/index.ts +import { defineBlockKind } from "@platforma-sdk/block-kind"; +import { name, version } from "../package.json" with { type: "json" }; + +export type BlockParams = { numbers?: number[] }; +const Params = z.object({ numbers: z.array(z.number()).optional() }).strict(); + +export const kind = defineBlockKind({ + name, + version, + parseInitializationParams: (value) => Params.parse(value), +}); +``` + +**`@platforma-sdk/model`** — the kind is now part of the model: + +```ts +const dataModel = new DataModelBuilder({ kind }) + .from("v1") + .init(({ params }) => ({ numbers: params?.numbers ?? [] })); + +export const platforma = BlockModelV3.create({ dataModel, kind }) + .templateParams((data) => ({ numbers: data.numbers })) + .args(...) + .done(); +``` + +`init` receives the kind's `params` (optional — a block may be created without a +template) and builds the block's initial storage from them. `templateParams()` is the +inverse: it projects block state back to the kind's params for export. Both are written in +live terms — the SDK marks the column identifiers in what the lambda returned, so nothing about +templates leaks into a block's own code. + +**`@milaboratories/pl-model-common`** — `BlockKindReference` + `formatKindRef`, the +`template-v1` document schema, the kind selector's semver ranges, and the `{ $ref: … }` wrapper +that marks a column identifier inside template params. `wrapTemplateRefs` puts those wrappers +on, in the block's own bundle where the reference system is already known; the template engine +stores what is inside verbatim and redirects the block ids textually, so it holds no model of +that system at all. + +**`@milaboratories/pl-middle-layer`** — `MiddleLayer.exportProjectAsTemplate(id)` and +`MiddleLayer.applyTemplateToProject(id, document)`, backing "Export Project as +Template…" and "Create Project from Template…". The template import path is public: +`parseProjectTemplateV1Yaml`, `validateTemplateV1ForApply`, `resolveTemplateEntries`, plus the +`BlockPackProvider` seam deciding which registries to consult. Entries resolve against the configured registries, ids are mapped to the blocks +they become, and each entry's params are offered to the block's kind for a shape check +before anything is created. + +**`@platforma-sdk/block-tools`** — the `kind` part in `.structure` with its own package +rules and scaffold, a `build-kind-manifest` command, kind-first publication (the kind +content is written to the registry's `kinds/` tree, source-hash guarded and idempotent, +before the facade — gated by a version-match check that hard-fails before any write), +and registry-side kind resolution. + +**`@milaboratories/ts-builder`** — `block-kind` build target (rolldown config + tsconfig). + +**BREAKING:** + +- `BlockModelV3.create(dataModel)` → `BlockModelV3.create({ dataModel, kind })`. A block + cannot omit its kind. +- `new DataModelBuilder()` → `new DataModelBuilder({ kind })`, and `init` takes + `({ params })` rather than no argument. +- `templateParams()` is required — `done()` throws without it. A block whose state + cannot be reduced to params returns `{}` explicitly, rather than exporting an entry + that silently applies as a default-initialized block. +- Every kind must declare `parseInitializationParams`. A kind whose params are genuinely empty + still declares one; it just rejects everything but `{}`. +- Publishing a block whose model was compiled against a kind requires the facade to + declare that kind as a dependency, at a matching version. Blocks declaring no kind + publish exactly as before. diff --git a/.changeset/milab-6648-example-blocks-declare-kind.md b/.changeset/milab-6648-example-blocks-declare-kind.md new file mode 100644 index 0000000000..15b23e8969 --- /dev/null +++ b/.changeset/milab-6648-example-blocks-declare-kind.md @@ -0,0 +1,10 @@ +--- +"@milaboratories/milaboratories.monetization-test": patch +"@milaboratories/milaboratories.pool-explorer": patch +"@milaboratories/milaboratories.ui-examples": patch +--- + +Declare a block kind. Each block gains a `kind/` package holding its init-params +contract, and its model is built with `new DataModelBuilder({ kind })` / +`BlockModelV3.create({ dataModel, kind })` and projects its params back via +`templateParams()`. diff --git a/docs/block-kinds-templates/01-kind-and-lifecycle-implementation-path.md b/docs/block-kinds-templates/01-kind-and-lifecycle-implementation-path.md new file mode 100644 index 0000000000..a5538e47f5 --- /dev/null +++ b/docs/block-kinds-templates/01-kind-and-lifecycle-implementation-path.md @@ -0,0 +1,838 @@ +# Track 1 — Kind + Lifecycle: Implementation Path + +**Status: implementation-path draft.** *Where* in the codebase to hook in, with pseudocode — not final code, and not the design. Companion to [`01-kind-and-lifecycle.md`](./01-kind-and-lifecycle.md) (the preamble; scope + open questions). + +**Authoritative design:** the `docs/text/work/projects/block-kind-and-templates/` mispec corpus — the **PR #198 rework** ("kind publishes with the facade", branch `feat/kind-publish-with-facade`, not yet on main). Produced by an adversarial multi-agent path-finding pass (2 architects + a judge per concern). Every entry point is cited as `path:line` — treat line numbers as anchors, they shift. + +> **Naming convention (reconciled in this doc):** `parseKindRef`/`formatKindRef` are the `{name}@{version}` **reference** codec — one home, `block_kind_ref.ts` (§3), imported by §4. `npmNameToKindPath` is the separate **npm-name → `{org,name}` path** helper — one home, `schema_kinds.ts` (§5), imported by §6. Two distinct functions; never one name for both. + +## Scope + +This document maps the **correct codebase entry points** for iteration-1 of the block-kind spec — the KIND subsystem end to end: **define** (`@platforma-sdk/block-kind` + `defineBlockKind`) → **build/bake reference** (kind build/pack target; bake the reference into `model.json` and the block manifest) → **publish + version-match** (kind-first publish flow with the pre-publish version gate) → **registry projection** (`kinds/` S3 tree + `overview.json` via the block reconciler) → **resolution** (middle-layer kind → concrete block resolution). The template engine (apply/export, `templateEntry`, the fixed native YAML lambda, and the add-block API) is a deliberate **follow-up document** and is not designed here. This is a **path-finding doc** — grounded entry points (`path:line`) plus pseudocode showing the shape to build — not a line-by-line implementation, and it does **not** decide final code. Every concern section carries its own "Risks & open" list; the cross-cutting and open-questions sections at the end collect what the whole path depends on. Grounded against the spec at `docs/text/work/projects/block-kind-and-templates/decisions.md` and `.../implementation.md`. + +## Dependency-Ordered Overview + +The six concerns form a mostly linear pipeline; each depends on the artifact the previous one produces. + +``` +1. sdk-kind @platforma-sdk/block-kind + defineBlockKind + → produces the compiled kind object (name/version imported from its package.json + phantom BlockParams type) + │ +2. kind-build ts-builder "block-kind" target + block-tools build-kind-manifest + → bundles the kind, reads name/version from the kind's package.json for the manifest, computes src/ sourceHash, writes manifest.json + │ (reads the kind's package.json; the bundler inlines the same import into kind.js) + │ +3. model-wiring DataModelBuilder(kind) + BlockModelV3.create({dataModel, kind}) + → bakes the {name}@X.Y.Z reference into model.json (container level) and the block manifest + │ (consumes the kind object from #1; shares the reference type/codecs) + │ +4. publish-flow publishBlock() orchestrator: version-match gate → publishKind (kind-first) → publishPackage + → hard-fails on a model↔facade kind-version mismatch before any S3 write + │ (reads the model's baked kindRef from #3's manifest field; calls publishKind from #5) + │ (source hash + manifest shape from #2) + │ +5. registry-projection publishKind (kinds/ tree, source-hash guard) + reconciler overview.json projection + → derives per-kind overview.json (resolvable versions × implementing blocks by channel) + │ (projection derived from the block manifest kind field from #3; ticket rides the block ticket) + │ +6. resolution pl-middle-layer resolveKind facade → block-tools kind_resolver (pure semver core) + → reads one overview.json (from #5), resolves selector → newest kind version → newest impl block + → emits a from-registry-v2 spec the existing add-block path consumes +``` + +Key cross-links: +- **#1 → #2, #3**: the compiled kind object's runtime shape — the full npm `name` + `version` (imported from the kind package's `package.json`; **no** separate `organization` field, per **A-0052**) — is the contract both the build target and the model wiring read. The `{org,name}` registry path is derived from the npm name via `parsePackageName`. This shape is now settled, not an open dependency. +- **#3 → #4, #5**: the reference type and its `{name}@X.Y.Z` string form live in one shared module (`block_kind_ref.ts`); the publish gate and the reconciler both read the baked reference (from `model.json`/manifest), never re-derive it. +- **#5 → #6**: the `kinds/{org}/{name}/overview.json` schema is **co-designed** — the reconciler (#5) is the sole writer, the resolver (#6) the sole reader; they must stay in lockstep. +- **#2, #4, #5** all share the **source-hash convention** (one sha256 over the sorted `src/` tree) and the **manifest-written-last commit-marker** pattern; the case convention (`.toUpperCase()`) is a live correctness seam across them. + +--- + +## 1. Define — `@platforma-sdk/block-kind` + `defineBlockKind` + +**Entry points** — grounded path:line, what to add/modify at each. + +- `core/platforma/sdk/block-kind/package.json` **(NEW)** — mirror `sdk/test/package.json:1-46` (verified: `name`, `files:["dist/**/*"]`, `main`/`module`/`types` + single-arm `exports` map `types|require|import → dist/index.{d.ts,cjs,js}`, full ts-builder script block `build/watch/check/formatter:check/linter:check/types:check/do-pack/fmt`). Runtime `dependencies`: **exactly one** — `@milaboratories/pl-model-common: workspace:*`. `devDependencies`: `@milaboratories/build-configs`, `@milaboratories/ts-builder`, `@milaboratories/ts-configs` (workspace:*), `typescript`, `@types/node`, plus `vitest` + `@vitest/coverage-istanbul` (catalog:) for the type-level test. **Explicitly omit** `@platforma-sdk/model`, `@milaboratories/pl-middle-layer`, `pl-client`, `pl-tree`, `computable` — every heavy dep `sdk/test` carries. +- `core/platforma/sdk/block-kind/tsconfig.json` **(NEW)** — copy `sdk/test/tsconfig.json` verbatim (verified: extends `@milaboratories/ts-configs/tsconfig.node.json`, `outDir ./dist`, `rootDir ./src`, `include:["src"]`). +- `core/platforma/sdk/block-kind/src/descriptor.ts` **(NEW)** — the compiled type surface: `unique symbol` phantom brand, `CompiledBlockKind` discriminated-union envelope (`kindSchema:"v1"` + runtime `name`/`version` + contravariant phantom slot), `InferBlockParams` extractor. +- `core/platforma/sdk/block-kind/src/index.ts` **(NEW)** — public surface: `defineBlockKind`, the `CompiledBlockKind`/`InferBlockParams` types, and `export type { PlRef } from "@milaboratories/pl-model-common"`. (PlRef grounded at `lib/model/common/src/ref.ts:5,26`, surfaced from that package's `index.ts:15` — verified.) +- `core/platforma/sdk/block-kind/src/index.test.ts` **(NEW)** — type-level test locking `InferBlockParams`. +- `core/platforma/pnpm-workspace.yaml:62` — add `- sdk/block-kind` immediately after `- sdk/eslint-config` (verified: sdk block is lines 58-62). The **only** edit to a pre-existing file. + +**Chosen path** — **Hybrid, leaning Proposal 2 on the type surface, Proposal 1 on file economy.** + +Both proposals are structurally identical (new `sdk/block-kind` sibling, trimmed-to-one-dep `sdk/test` skeleton, type-only PlRef re-export, phantom-generic descriptor, an explicit `meta` argument carrying `{ name, version }` — the final form settled by **A-0052**, see below — and one workspace-list line). I adopt: + +- **Proposal 2's contravariant phantom brand** (`readonly [BLOCK_PARAMS]?: (p: BlockParams) => void`) over Proposal 1's covariant `[BLOCK_PARAMS]?: BlockParams`. The function-parameter slot makes `BlockParams` contravariant under `strictFunctionTypes`, so a kind typed for `{ ref: PlRef; k: number }` is **not** silently assignable to one typed for `{ ref: PlRef }`. Zero runtime cost, strictly more type-safe. `InferBlockParams` still recovers the declared params (single inference candidate). +- **Proposal 2's discriminated-union envelope** (`kindSchema:"v1"`). The tag is one string field at near-zero cost and is the exact hook the deferred template-engine / sandbox phases use: they add a `CompiledBlockKindV2` union arm and consumers narrow on `kindSchema`, so v1 kinds keep compiling untouched. This reuses the schema-versioning pattern the code map flags for the workflow envelope. +- **Proposal 1's file economy** over Proposal 2's four-file split. `refs.ts` holding a single `export type` line is pointless indirection. Land it as `descriptor.ts` (type surface + version union) + `index.ts` (`defineBlockKind` + re-exports). Two files, not four. +- **`defineBlockKind({ name, version })` takes a `{ name, version }` argument, sourced from the kind's own `package.json`** (settled by **A-0052** / Q-0005) — the kind's `src/index.ts` imports `{ name, version }` from `../package.json` (`with { type: "json" }`) and passes them straight in: `export const kind = defineBlockKind({ name, version })`. The bundler (rolldown, `external: () => false`) inlines that JSON import — tree-shaking it to just the two strings — into the compiled `kind.js`; there is **no** build-time injection and **no** rolldown/oxc `define`. The pseudocode below (the `meta: { name, version }` signature) is the **final** form, not an interim baseline. The anti-drift essence is preserved: identity comes from `package.json` (imported, never hand-typed literals). + +**Decisive rejection of the "inline PlRef" fork** that Proposal 1 floats as its main open con: do **not** inline the ~20-line PlRef type for a zero-dep package. That would fork the reference type and violate the spec's explicit "introduces no reference type of its own / reuse the existing PlRef." Keep `@milaboratories/pl-model-common` as the single runtime dependency, consumed **type-only** — see Risks for why "one dependency" holds despite its transitive tree. + +**Pseudocode** + +`src/descriptor.ts` (NEW): +```ts +// Phantom brand — declared, never assigned at runtime. +declare const BLOCK_PARAMS: unique symbol; + +// Discriminated-union envelope. Future phases add a `"v2"` arm; consumers +// narrow on `kindSchema`, so v1 descriptors never break. +export interface CompiledBlockKindV1 { + readonly kindSchema: "v1"; + readonly name: string; // full npm package name; no separate organization field + readonly version: string; + // Contravariant phantom: carries BlockParams as a TYPE only, no runtime bytes, + // and blocks structural widening between kinds of different param shapes. + readonly [BLOCK_PARAMS]?: (p: BlockParams) => void; +} + +export type CompiledBlockKind = CompiledBlockKindV1; + +// The contract the deferred DataModelBuilder.init / BlockModelV3.create +// consumers use to pull BlockParams off a kind object. +export type InferBlockParams = + K extends CompiledBlockKind ? P : never; +``` + +`src/index.ts` (NEW): +```ts +import type { CompiledBlockKind } from "./descriptor"; +export type { CompiledBlockKind, InferBlockParams } from "./descriptor"; + +// Reuse the canonical reference type from its lightest owner — no new ref type. +export type { PlRef } from "@milaboratories/pl-model-common"; + +export function defineBlockKind(meta: { + name: string; // full npm package name + version: string; +}): CompiledBlockKind { + // Frozen, serializable v1 descriptor. Phantom slot is never assigned. + return Object.freeze({ + kindSchema: "v1" as const, + name: meta.name, + version: meta.version, + }); +} +``` + +a kind author's own `src/index.ts` (usage — identity comes from `package.json`, never literals): +```ts +import { defineBlockKind, type PlRef } from "@platforma-sdk/block-kind"; +// The bundler (rolldown, external: () => false) inlines this JSON import — tree-shaken +// to just the two strings — into the compiled kind.js. No build-time injection. +import { name, version } from "../package.json" with { type: "json" }; + +export const kind = defineBlockKind<{ ref: PlRef; n: number }>({ name, version }); +``` + +`src/index.test.ts` (NEW): +```ts +import { expectTypeOf } from "vitest"; +import { defineBlockKind, type InferBlockParams, type PlRef } from "./index"; + +const k = defineBlockKind<{ ref: PlRef; n: number }>({ + name: "@platforma-open/milaboratories.demo.kind", version: "1.0.0", +}); +// Locks the contract the future init/create wiring relies on. +expectTypeOf>().toEqualTypeOf<{ ref: PlRef; n: number }>(); +``` + +`package.json` (NEW, runtime-dep shape — the load-bearing part): +```jsonc +{ + "name": "@platforma-sdk/block-kind", + "dependencies": { "@milaboratories/pl-model-common": "workspace:*" }, // exactly one + // heavy sdk/test deps deliberately absent: @platforma-sdk/model, pl-middle-layer, ... +} +``` + +`pnpm-workspace.yaml` (edit at line 62): +```yaml +- sdk/eslint-config +- sdk/block-kind # ADD +``` + +**Why this over the alternatives** + +- **vs. inlining PlRef for a true zero-dep package** (Proposal 1's floated alternative): rejected — forks the reference type and directly violates the spec's "reuse the existing PlRef, introduce no reference type of its own." The single-source-of-truth requirement outranks a literal-zero-dependency reading. +- **vs. Proposal 1's covariant phantom** (`?: BlockParams`): rejected — allows silent widening between kinds. The contravariant function-slot form is strictly safer at zero cost. +- **vs. Proposal 2's four-file split**: rejected — a one-line `refs.ts` is dead indirection for a package this small. Two files carry the same forward-compat structure (version union lives in `descriptor.ts`). +- **vs. adding `defineBlockKind` to `@platforma-sdk/model`**: rejected on blast radius and the hard constraint — it would pull the full SDK into every kind author. Grounded: `defineBlockKind`/`block-kind` exist nowhere (grep empty), so this is pure addition with zero regression surface in model/middle-layer. The only pre-existing-file change is one workspace line — fully reversible. + +Blast radius: 4 new files + 1 workspace line. Nothing existing changes behavior. + +**Risks & open** + +- **"One dependency" — literal vs. transitive.** `@milaboratories/pl-model-common` brings a moderate transitive tree (verified: `helpers`, `pl-error-like`, `canonicalize`, `es-toolkit`, `zod`) but **crucially not** `@platforma-sdk/model` or `pl-middle-layer`. Because PlRef is consumed via `export type`, ts-builder erases it — the emitted `dist/index.js`/`.cjs` import **nothing** external, so the runtime footprint is just the `defineBlockKind` factory. The dependency must stay a real `dependencies` entry (not `devDependencies`) because PlRef appears in block-kind's public `.d.ts`, so downstream consumers need it for type resolution. "Never pulls in the full SDK" holds structurally; "one dependency" holds as one direct runtime dep with zero emitted runtime imports. +- **Type-only contract is convention, not enforced.** A careless future edit could `import { PlRef }` (value) and silently pull `zod`/`es-toolkit` into runtime. Mitigation: rely on `verbatimModuleSyntax` (from the shared `ts-configs`) + an `@typescript-eslint/consistent-type-imports` lint rule. **Open:** confirm the shared `ts-configs.node.json` / eslint-config already enforces type-only imports before treating this as guaranteed rather than conventional. +- **Kind-reference string format is unverified.** Whether the recorded reference is `{name}@X.Y.Z` or `{organization}/{name}@X.Y.Z` is not settled against the model.json / manifest schema. Deliberately kept `name`/`version` as the source-of-truth fields (no separate `organization` field — the full npm name encodes org, per A-0052); the formatter belongs to the `BlockModelV3.create` / manifest wiring concern, not here. Do not guess it in this package. +- **Q-0005 (kind build mechanism) — RESOLVED (A-0052).** `defineBlockKind({ name, version })` takes a `{ name, version }` argument; the kind's `src/index.ts` imports `{ name, version }` from its own `package.json` (`with { type: "json" }`) and passes them in. The bundler (rolldown, `external: () => false`) inlines that JSON import — tree-shaken to the two strings — into the compiled `kind.js`; there is no build-time injection and no rolldown/oxc `define`. The `block-kind` build target needs no metadata knowledge and emits a hand-readable `.d.ts` on the stock `ts-builder build` toolchain with no new tooling; the public type contract is unchanged. +- **Phantom is compile-time only.** No runtime guarantee that declared `BlockParams` matches actual usage — the `index.test.ts` type-level assertion is the guard that locks `InferBlockParams` inference against regression. + +--- + +## 2. Build/Bake — Kind Build/Pack Target + +Verdict: **hybrid**. Take Proposal 1's minimal ts-builder half and its verified `hashDirSync` reuse; graft on Proposal 2's one real improvement — a commander-free core (`buildKindDist`) so the deferred publish-time guard reuses the exact hash + manifest shape. Reject Proposal 2's `hashDirSync` port and its `entries[]` parameterization (both refuted below). + +**Entry points** — grounded path:line, what to modify or add. + +- `core/platforma/tools/ts-builder/src/commands/utils/config-manager.ts:4` — add `"block-kind"` to the `TargetType` union (currently ends at line 11). +- `config-manager.ts:39` — add a `block-kind` row to `TARGET_CONFIG_MAP` immediately after the `block-facade` row (36-39): `{ filename: "rolldown.block-kind.config.js", outputPath: "./build.block-kind.config.js" }`. (Naming convention confirmed: `rolldown..config.js` prefix + flat filename.) +- `config-manager.ts:51` — add `"block-kind": "tsconfig.block-kind.json"` to `TSCONFIG_MAP`. +- `config-manager.ts:182` — add `"block-kind": "node"` to `TARGET_TO_OXLINT_MAP` (reuse the `node` preset exactly as `block-facade` does at line 182). `OXLINT_CONFIG_MAP` needs **no** change — `node` already exists. +- `core/platforma/tools/ts-builder/src/configs/utils/createRolldownBlockKindConfig.ts` — NEW. Copy of `createRolldownBlockFacadeConfig.ts` (which is already factored with an internal `entry()` helper + `props.output`) but returning a **single** entry `entry("kind", "src/index.ts")`. Keep `external: () => false` and `dts({ tsconfig: "tsconfig.json", emitDtsOnly: false, sourcemap: true })`. Emits `kind.js` + self-contained `kind.d.ts`. +- `core/platforma/tools/ts-builder/src/configs/rolldown/block-kind.config.ts` — NEW. `export default defineConfig(createRolldownBlockKindConfig())` (sibling of `block-facade.config.ts`). +- `core/platforma/tools/ts-builder/src/configs/tsconfig.block-kind.json` — NEW. Mirror `tsconfig.block-facade.json`. +- `core/platforma/tools/ts-builder/src/commands/build.ts:36` — NO change. `block-kind` is not a vite target, so it falls through to `buildWithRolldown` automatically. Verify only. +- `core/platforma/tools/block-tools/src/v2/build_kind_dist.ts` — NEW. Commander-free `buildKindDist(opts)` core: read `name`/`version` from the kind package's `package.json`, compute one sha256 over `src/` via the **reused** `util.hashDirSync`, assemble the manifest, write `manifest.json` **last** (build_dist.ts:82-90 commit-marker pattern). Beside `build_dist.ts`. +- `core/platforma/tools/block-tools/src/cmd/build-kind-manifest.ts` — NEW. Thin commander wrapper (build-model.ts shape) delegating to `buildKindDist`. +- `core/platforma/tools/block-tools/src/cli.ts:27` — import and `program.addCommand(buildKindManifestCommand())` alongside `buildModelCommand`/`packCommand`. +- `core/platforma/tools/block-tools/src/structure/rules/block-package-json.ts:79` — **ACTIVATED (A-0053: kind is a confirmed block component).** The structurer discovers `kind/` alongside `model/`/`workflow/`/`ui/`, so this rule asserts `build: "ts-builder build --target block-kind && block-tools build-kind-manifest"`, `check: "ts-builder type-check --target block-kind"`, plus the `blockComponents` mapping for the kind. + +**Chosen path** — prose. + +The ts-builder half is Proposal 1 verbatim: a near-copy of the `block-facade` rows across the single closed registry (`config-manager.ts`), a single-entry `createRolldownBlockKindConfig.ts`, its thin config file, and a tsconfig template. No dispatch change. The facade's two-pass `index`/`AGENTS` split exists only because it has two entries; the kind has one, so the split is dropped and the code is strictly simpler. + +The block-tools half reuses `hashDirSync` from `@platforma-sdk/package-builder-lib` (confirmed already a `workspace:*` dependency at block-tools/package.json:46; exported as `util.hashDirSync` via index.ts:4) — **no port, no new dependency, no drift risk**. But the manifest logic is structured as Proposal 2 argued: a commander-free `buildKindDist()` core plus a thin `build-kind-manifest` command wrapper, because the deferred publish-time source-hash guard (a separate concern) must recompute and compare this exact hash and read this exact manifest shape. Embedding it in the command action (build-model.ts style) would force that concern to duplicate the logic; a commander-free core (build_dist.ts style) lets it `import` directly. + +Identity is read from the kind package's `package.json` — its `name` and `version` (no separate `org` field; the registry `{org,name}` path is derived downstream from the full npm name via `parsePackageName`). `buildKindDist` no longer imports the compiled bundle, so there is no ESM `await import()` deviation. The src-tree hash is written **upper-case** (`hashDirSync(src).digest("hex").toUpperCase()`) to match block-tools' `calculateSha256` convention (util.ts:33) so the future publish-side comparator never fails on a case mismatch. + +**Pseudocode.** + +```ts +// config-manager.ts +type TargetType = ... | "block-facade" | "block-kind" | ...; +TARGET_CONFIG_MAP["block-kind"] = { filename: "rolldown.block-kind.config.js", + outputPath: "./build.block-kind.config.js" }; +TSCONFIG_MAP["block-kind"] = "tsconfig.block-kind.json"; +TARGET_TO_OXLINT_MAP["block-kind"] = "node"; // same preset block-facade uses +``` + +```ts +// createRolldownBlockKindConfig.ts (facade clone, single entry) +export function createRolldownBlockKindConfig(props?): RolldownOptions[] { + const output = props?.output ?? "dist"; + return [{ + input: { kind: "src/index.ts" }, + external: () => false, // force-inline -> self-contained kind.d.ts + plugins: [dts({ tsconfig: "tsconfig.json", emitDtsOnly: false, sourcemap: true })], + output: { dir: output, format: "es", entryFileNames: "[name].js", sourcemap: true }, + transform: { target: "ES2022" }, + }]; // emits kind.js + kind.d.ts +} +``` + +```ts +// block-tools/src/configs/rolldown/block-kind.config.ts +export default defineConfig(createRolldownBlockKindConfig()); +``` + +```ts +// block-tools/src/v2/build_kind_dist.ts (commander-free core) +import { util } from "@platforma-sdk/package-builder-lib"; // hashDirSync already available +export async function buildKindDist({ modulePath = ".", srcDir = "src", dst = "dist" }) { + const pkg = JSON.parse(await readFile(join(modulePath, "package.json"), "utf-8")); // read identity from package.json, not the bundle + if (!pkg?.name || !pkg?.version) // shape settled by A-0052: full npm name + version, no org field + throw new Error('kind package.json missing name/version'); + const sourceHash = util.hashDirSync(srcDir).digest("hex").toUpperCase(); // match calculateSha256 case + const manifest = { schema, kind: { name: pkg.name, version: pkg.version }, // full npm name; {org,name} derived downstream via parsePackageName + sourceHash, timestamp: Date.now() }; + // ... (optionally per-artifact sha256 of kind.js/kind.d.ts via calculateSha256) ... + await writeFile(join(dst, "manifest.json"), JSON.stringify(manifest)); // LAST = commit marker +} +``` + +```ts +// block-tools/src/cmd/build-kind-manifest.ts (thin wrapper) +export function buildKindManifestCommand() { + return new Command("build-kind-manifest") + .option("-i, --modulePath ", "kind package dir", ".") + .action((flags) => buildKindDist({ modulePath: flags.modulePath })); +} +// cli.ts: program.addCommand(buildKindManifestCommand()); +``` + +**Why this over the alternatives.** + +- **Reject Proposal 2's `hashDirSync` port.** Its stated rationale — "block-tools is a low-level published tool; depending on package-builder-lib inverts layering" — is refuted by block-tools/package.json:46: the dependency **already exists**. There is no layering to invert. Porting therefore only creates a second copy of a 30-line security-relevant algorithm that must be kept bit-identical to the canonical one (the publish-time guard compares against it), guarded by a "shared test vector + doc comment" maintenance obligation that reuse eliminates outright. Proposal 1's reuse is strictly lower blast radius and zero drift risk. +- **Reject Proposal 2's `entries[]` parameterization.** Its claim that parameterizing avoids "re-opening the target registry" for deferred phases is false: the registry maps target -> config filename; adding entrypoints later means editing `createRolldownBlockKindConfig.ts`'s returned array, never the registry, regardless of whether an `entries` param exists. Nothing calls the factory with a custom entry set today, so the param is pure YAGNI. The facade itself hardcodes its entries — following that established pattern (hardcode, extend by editing the factory) is the lower-surface choice. +- **Adopt Proposal 2's commander-free core.** This is its one substantive win. `buildBlockPackDist` (build_dist.ts) already establishes the commander-free-core precedent in this very package, and the deferred publish-time guard genuinely needs to reuse the hash computation and manifest shape without CLI coupling. Splitting `buildKindDist` (core) from `build-kind-manifest` (wrapper) costs one extra file and pays off directly at the next phase. +- **Both proposals' shared choices are correct and kept:** single closed-registry edit (reviewable in one diff), no `build.ts` dispatch branch (non-vite -> rolldown fallback confirmed), `external:()=>false` + `dts({emitDtsOnly:false})` for the self-contained `kind.d.ts`, manifest-written-last commit marker, structurer activated (A-0053: kind is a confirmed block component). + +**Risks & open.** + +- **Q-0005 (kind build mechanism) — RESOLVED (A-0052).** `defineBlockKind({ name, version })` takes a `{ name, version }` argument; the kind's `src/index.ts` imports `{ name, version }` from its own `package.json` (`with { type: "json" }`) and passes them in, and the bundler (rolldown + rolldown-plugin-dts, `external: () => false`) inlines that JSON import into the compiled `kind.js` — no build-time injection, no `define`. `buildKindDist` reads `name`/`version` directly from the kind package's `package.json` (not off the compiled bundle), so its manifest identity is written against a settled shape. **The earlier `organization`-vs-`org` field-name mismatch is resolved by elimination:** the compiled descriptor carries no separate organization/org field at all — only the full npm `name` + `version` — and the registry `{org,name}` path is derived from that npm name via the existing `parsePackageName` parser (`core/platforma/tools/block-tools/src/v2/source_package.ts`). +- **Case convention is a live correctness seam.** `hashDirSync(...).digest("hex")` is lowercase; block-tools' `calculateSha256` is upper-case (util.ts:33). If the publish-time comparator (separate concern) hashes with one convention and compares against the other, the guard reports "different" on every run. Mitigation baked in: `.toUpperCase()` at the manifest-write site, documented there. +- **Compiled-config filename mapping unverified end-to-end.** `TARGET_CONFIG_MAP` expects the flat `rolldown.block-kind.config.js`, but the source lives at `configs/rolldown/block-kind.config.ts`. The facade's identical convention works, so this is low-risk, but verify the ts-builder self-build emits the flat name before relying on it (`getConfigPath(filename)` resolution). +- **Scope boundary (honored):** this concern produces the bundle, `kind.d.ts`, and `manifest.json` with its computed hash. The publish-time source-hash comparison (absent->store / equal->no-op / differ->hard-fail) and the S3 upload are the publication-lifecycle concern; the commander-free `buildKindDist` is shaped so that concern imports the hash + manifest shape without duplicating logic. + +--- + +## 3. Bake Reference — Model→Kind Wiring, `model.json` + Block Manifest + +**Entry points** — grounded path:line, what to modify or add. + +- `core/platforma/lib/model/common/src/bmodel/block_kind_ref.ts` **(NEW)** — single shared home for the reference type + codecs: `BlockKindReference` (branded string, the on-wire form), `formatKindRef(kind) → "{name}@{version}"`, `parseKindRef(ref) → { name, version }`, and a `z.string()` schema. Export from the bmodel index. Every reader (model.json runtime path, manifest reconciler, future template engine) imports from here — the one place that decides whether the name segment is org-qualified. +- `core/platforma/sdk/model/src/block_model.ts` (new local type) — `BlockKind = { reference: BlockKindReference; readonly __params?: Params }`. Minimal interface the SDK consumes so `sdk/model` stays decoupled from the `@platforma-sdk/block-kind` package (whose build mechanism is settled by A-0052 / Q-0005). +- `core/platforma/sdk/model/src/block_migrations.ts:5` — `DataCreateFn = () => T` → `DataCreateFn = (args: { params?: Params }) => T`. Object-arg so future fields (services, resolved refs) extend without a signature break. +- `core/platforma/sdk/model/src/block_migrations.ts:181` (`MigrationChainBase.init`) — parameter becomes `DataCreateFn`; thread a third `Params` generic through `MigrationChainBase` and its two subclasses + `DataModelInitialChain` (the same mechanism that already carries `Current`/`Transfers`). +- `core/platforma/sdk/model/src/block_migrations.ts:498` (`DataModelBuilder`) — add `` generic + `constructor(kind: BlockKind)`. Store `kind.reference` on the builder; `from()` seeds `Params` into the chain. Update the two JSDoc examples. +- `core/platforma/sdk/model/src/block_migrations.ts` (`DataModel`, ~:530) — add a `Params` type generic AND a private `kindRef?: BlockKindReference` set via `FROM_BUILDER`, with an `@internal` getter. The type generic lets `create()` constrain its `kind` at compile time; the runtime field powers the value cross-check. +- `core/platforma/sdk/model/src/block_model.ts:106` (`BlockModelV3Config`) — add `kind: BlockKindReference`. +- `core/platforma/sdk/model/src/block_model.ts:170` (`BlockModelV3.create`) — positional → `create({ dataModel, kind })`; set `config.kind = kind.reference`; assert `kind.reference === dataModel.kindRef`. Constrain the type: `kind: BlockKind` where `Params` comes from `DataModel`. +- `core/platforma/sdk/model/src/block_model.ts:607` (`done()`, non-UI branch) — set `kind: this.config.kind` at the **container** level of `blockConfig`, alongside `code` (NOT inside `v4`). Rides into model.json via the existing `JSON.stringify(config)` in build-model.ts — no write-path change. +- `core/platforma/lib/model/common/src/bmodel/container.ts:6` (`BlockConfigContainer`) — add `readonly kind?: BlockKindReference;` next to `code`. +- `core/platforma/tools/block-tools/src/v2/build_dist.ts:44` — add `modelKindReference(descriptionRelative, dst)`, a near-copy of `workflowRequiredCapabilities` (:22): read the model component's `model.json`, pull `container.kind`, fail-safe `undefined`. Set it on the description before `BlockPackManifest.parse` (:82). +- `core/platforma/lib/model/middle-layer/src/block_meta/block_description.ts:15,36` — add `kind?: BlockKindReference` to the `BlockPackDescription` type and a `kind: z.string().optional()` to `CreateBlockPackDescriptionSchema`, mirroring `featureFlags` (:45). +- `core/platforma/tools/block-tools/src/cmd/build-model.ts` — **no code change**; verify `kind` lands at container level in `dist/model.json`. +- `blocks/clonotype-browser/model/src/dataModel.ts:38` and `.../index.ts:52` — migrate call sites to `new DataModelBuilder(kind)` + params-carrying init, and `BlockModelV3.create({ dataModel, kind })`. + +**Chosen path** — Hybrid, anchored on Proposal 2, with three corrections pulled from Proposal 1 / the code. + +Proposal 2 wins the two architecture decisions that matter for a spec with deferred phases, and both are grounded in the code, not preference: + +1. **Container-level bake, not `v4`.** `BlockConfigContainer` already carries version-independent payload at the container level — `code` (container.ts:12) lives beside `v4`/`v3`, orthogonal to the render envelope. Kind identity is the exact analogue of `code`: needed regardless of which config version renders, and a future `v5` inherits it for free. Placing it in `v4` (Proposal 1) forces `build_dist.ts` to reach into `config.v4.kind` — coupling to the render-envelope layout that Proposal 1 itself lists as a con. Container-level placement *removes* that coupling; the reader does `config.kind`. + +2. **One shared reference type + codecs, not an inline string in two files.** Both readers (runtime templateEntry, manifest reconciler) and the deferred template engine must agree on the reference shape. A shared `block_kind_ref.ts` is the single source of truth; `.passthrough()` on the description schemas is a tolerance, not a home. + +Three corrections to Proposal 2: + +- **Keep the wire form a string, faithful to the spec's `{name}@{version}`** (Proposal 1's shape), rather than storing structured `{name, version}` at rest. Readers overwhelmingly need identity equality (does block X implement kind Y?), for which an opaque canonical string is ideal and avoids premature version-aware logic. `formatKindRef`/`parseKindRef` in the shared module give parts to any reader that needs them, and localize the one open decision (is the name segment org-qualified?) to a single function. +- **Make the divergence guard both compile-time and runtime.** Thread `Params` onto `DataModel` as a real type generic so `create({ dataModel, kind })` can constrain `kind: BlockKind` — a type mismatch between the builder's kind and create's kind fails at compile time. The runtime `kindRef` cross-check (Proposal 2) then catches value-level mismatch of two kind objects with the same param type. The builder is handed the object anyway (spec target `new DataModelBuilder(kind)`), so capturing its reference is nearly free. +- **`Params = never`** (Proposal 2) over `unknown` (Proposal 1): stricter, and `{ params?: never }` correctly says "this block reads no params yet." + +**Pseudocode** + +```ts +// NEW: lib/model/common/src/bmodel/block_kind_ref.ts +export type BlockKindReference = string & { readonly __kindRef: unique symbol }; +export const BlockKindReferenceSchema = z.string(); +// single composition point for the {fullNpmName}@{version} reference (A-0052): the full npm name already encodes org, derived downstream via parsePackageName +export const formatKindRef = (k: { name: string; version: string }) => + `${k.name}@${k.version}` as BlockKindReference; +export const parseKindRef = (r: BlockKindReference) => { + const at = r.lastIndexOf("@"); + return { name: r.slice(0, at), version: r.slice(at + 1) }; +}; +``` + +```ts +// sdk/model/src/block_migrations.ts +export type DataCreateFn = (args: { params?: Params }) => T; + +class DataModelBuilder { + readonly #kindRef: BlockKindReference; + constructor(kind: BlockKind) { this.#kindRef = kind.reference; } + from(v: string): DataModelInitialChain { + return chain.seed({ kindRef: this.#kindRef /* value */ }); // Params flows via the type + } +} + +// MigrationChainBase +init(initialData: DataCreateFn): DataModel { + return DataModel[FROM_BUILDER](initialData, this.transfers, this.kindRef); +} +``` + +```ts +// sdk/model/src/block_model.ts +type BlockKind = { reference: BlockKindReference; readonly __params?: Params }; + +interface BlockModelV3Config { /* ... */ kind: BlockKindReference; } + +static create, Params = never, Transfers = {}>( + args: { dataModel: DataModel; kind: BlockKind }, +): BlockModelV3 { + const { dataModel, kind } = args; + // runtime guard: builder-kind vs create-kind value identity + if (dataModel.kindRef && dataModel.kindRef !== kind.reference) + throw new Error(`kind mismatch: model ${dataModel.kindRef} vs ${kind.reference}`); + return new BlockModelV3({ /* ...defaults */, dataModel, kind: kind.reference }); +} + +// done(), non-UI branch — container level, beside `code` +const blockConfig: BlockConfigContainer = { + v4: { configVersion: 4, /* unchanged */ }, + kind: this.config.kind, // <-- baked into model.json via JSON.stringify(config) + sdkVersion: PlatformaSDKVersion, renderingMode: /*...*/, sections: /*...*/, outputs: /*...*/, +}; +``` + +```ts +// tools/block-tools/src/v2/build_dist.ts (near-copy of workflowRequiredCapabilities) +async function modelKindReference(desc, dst): Promise { + const model = desc.components.model; // relative path into dst (block_components.ts:72) + try { + const cfg = JSON.parse(await fsp.readFile(path.resolve(dst, model.path), "utf-8")); + return cfg.config?.kind ?? undefined; // container-level, fail-safe + } catch { return undefined; } +} +// inside buildBlockPackDist, before BlockPackManifest.parse: +const kindRef = await modelKindReference(descriptionRelative, dst); +if (kindRef) descriptionRelative.kind = kindRef; // top-level, like featureFlags +``` + +```ts +// block_description.ts — mirror featureFlags +export type BlockPackDescription = { id; components: C; meta: M; featureFlags?; kind?: BlockKindReference }; +// schema: kind: BlockKindReferenceSchema.optional(), +``` + +```ts +// blocks/clonotype-browser/model — migrated call sites +export const blockDataModel = new DataModelBuilder(kind) + .from("v1") + .init(({ params }) => params ?? { /* defaults */ }); +export const platforma = BlockModelV3.create({ dataModel: blockDataModel, kind }).args(/*...*/).done(); +``` + +**Why this over the alternatives** + +- **Rejected Proposal 1's `v4`-level bake.** It couples `build_dist.ts` to the render-envelope layout (a con Proposal 1 states), and it conflates block identity with render versioning. `code` at container level (container.ts:12) is the grounded precedent for the opposite. Blast radius is identical; container placement is strictly cleaner. +- **Rejected Proposal 2's structured-at-rest reference.** The spec names the reference `{name}@{version}` — a string. Readers need equality/grouping, not arithmetic on version parts. Structured storage invites premature version logic and multiplies the shape across the wire. A shared type + `format`/`parse` codecs captures the single-source-of-truth benefit at the string form the spec asks for. +- **Rejected the "drop the second kind param, derive create's ref from dataModel" purist option.** It diverges from the spec target `create({ dataModel, kind })`. Instead the double-pass is turned from a footgun into a compile-time constraint (`kind: BlockKind`) plus a runtime assert. +- Both proposals correctly reuse the `workflowRequiredCapabilities` mirror precedent (build_dist.ts:22-72) and the `featureFlags` optional-field precedent (block_description.ts:45); the manifest path is low-novelty either way. + +**Risks & open** + +- **Q-0005 (kind build mechanism / kind object shape) — RESOLVED (A-0052).** The compiled kind object exposes runtime `{ name, version }` — the full npm `name` + `version` imported from the kind package's `package.json` (via `defineBlockKind({ name, version })`) — plus a phantom `Params` type; there is no separate `organization` field. The reference format is settled as `{fullNpmName}@X.Y.Z`: the full npm name already encodes org, so no separate org-qualification segment is needed, and the `{org,name}` path is derived downstream via `parsePackageName`. `formatKindRef` remains the single composition point for the reference string. +- **Runtime reader coupling — NEEDS VERIFICATION.** Container-level placement assumes the runtime reader (templateEntry / `extractConfigGeneric`) can read `config.kind` off the container. If that reader is hard-wired to descend into `v4`, fall back to `BlockConfigV4Generic.kind` (Proposal 1's location). Verify before implementing; it decides one line in two files. +- **Migration sequencing.** Making `DataModelBuilder`'s constructor kind **required** breaks every existing V3 block at once (create's object-destructure is likewise breaking). Options: (a) required + one codemod sweep across all V3 blocks; (b) a transition window with `constructor(kind?)` and fail-safe `undefined` reference (reconciler simply can't project kind-less blocks yet, matching the workflowCapabilities fail-safe). Legacy V1 blocks (block_model_legacy.ts) must reach V3 first — prerequisite, out of scope (see Cross-Cutting). +- **Generic threading needs a compile check, not just a read.** Adding `Params` across `MigrationChainBase`, both subclasses, `DataModelInitialChain`, `DataModel`, and `create` must be verified to infer through `.from().migrate().transfer().init()` end to end. +- **Two copies cannot actually drift** in the current build: `build_dist` reads the manifest reference *out of* the already-baked `model.json`, so there is one value source (done()'s container.kind). The cross-check guards the distinct risk of a block author passing two different kind objects (builder vs create), not copy divergence. +- Out of scope (respected): PlRef / template-local→concrete rewrite of init params (template engine); the `@platforma-sdk/block-kind` package build itself (settled by A-0052, owned by #1/#2); the facade↔kind direct dependency (settled by A-0053 — kind is a fourth block component — owned by #4's `resolve-refs` and #2's structurer). + +--- + +## 4. Publish + Version-Match — Kind-First Publish Flow + +**Entry points** — grounded path:line, what to modify or add at each. + +- `core/platforma/tools/block-tools/src/cmd/publish.ts:93` — MODIFY. Replace the single `await registry.publishPackage(manifest, fileReader)` call with `await publishBlock(registry, manifest, manifestRoot, fileReader)`. `manifestRoot` (already resolved at :84) is the facade cwd — the read root for the facade's declared kind dep. The channel marker (:97-100), coords write (:106-111) and refresh (:113) tail stays UNCHANGED and still runs only after `publishBlock` resolves. The action shrinks to flag-parse + adapter. +- `core/platforma/tools/block-tools/src/v2/publish-block.ts` — NEW. Orchestrator `publishBlock(registry, manifest, facadeDir, fileReader)`: (1) resolve both refs, (2) run pure gate — throws before any I/O, (3) `registry.publishKind(...)` — S3 `kinds/` tree, idempotent, (4) `registry.publishPackage(...)` — facade, unchanged. This is the single forward-compat seam the deferred phases extend. +- `core/platforma/tools/block-tools/src/v2/kind/version-match.ts` — NEW. Pure `checkKindVersionMatch(modelKindRef, facadeKindDep): void` throwing a typed `KindVersionMismatchError`; it **imports** `parseKindRef` (the `{name}@{version}` codec, §3) rather than redefining it. No I/O. Directly unit-tested with the `exitOverride`/`mkdtemp` harness already in `publish.test.ts` — and, being pure, tested with zero registry I/O. +- `core/platforma/tools/block-tools/src/v2/kind/resolve-refs.ts` — NEW. `readModelCompiledKindRef(manifest)` reads `manifest.description.components.model`'s recorded kind ref (the field the record-kind-ref concern adds; no `model.json` re-read — same field the reconciler consumes). `readFacadeKindDependency(facadeDir)` reads the facade-side kind dep from `package.json`. This module is the single reader of the facade's **direct** kind dependency (A-0053: the kind is a fourth block component, so the facade declares the kind directly in its `dependencies`, not transitively through the model). +- `core/platforma/tools/block-tools/src/v2/registry/registry.ts:474` — DEPENDENCY (owned by the S3-kinds concern, not authored here). `publishPackage` stays byte-for-byte unchanged. A sibling `publishKind(kindManifest, fileReader)` mirrors the content-first / manifest-last / `marchChanged` pattern PLUS a source-hash guard modeled on `addPackageToChannel:397-408` read-then-decide (absent → write; equal `sourceHash` → idempotent no-op; different → throw for immutability). This concern only CALLS it, kind-first. +- `core/platforma/tools/block-tools/src/v2/registry/schema_public.ts:22` + `src/util.ts:28` — DEPENDENCY (S3-kinds concern). `kinds/` path-helper analogs of `packageContentPrefix`/`packageUpdateSeedPath`, and `hashSourceTree(srcDir)` folding one `calculateSha256` over the sorted src tree. Consumed by `publishKind`, not written here. + +**Chosen path** — Proposal 2 wins, with one blast-radius trim. The three-layer split (pure gate + facade-dep reader + orchestrator seam) is adopted because (a) the facade→kind dependency read deserves a single home — settled by A-0053 as a **direct** facade dependency, so any future change to how that read works stays in one place — and (b) the spec has deferred phases that will add more publishable artifacts and more pre-publish gates. Proposal 2 isolates (a) in one function and gives (b) a seam that is not a commander action. Proposal 1's inline approach couples the gate to the command harness and buries the facade-dep read inside `cmd.action`, making the load-bearing check testable only through a full command build. + +The trim vs. literal Proposal 2: `publishBlock` sequences only the two publishes and the gate. The channel marker, coords write and refresh tail stay in `publish.ts` unchanged — Proposal 2 folded them into the orchestrator, which is needless churn on code unrelated to kinds. `publishBlock` takes no `opts` bag yet (YAGNI — add parameters when a deferred phase needs one). + +Deliberate, confirmed-acceptable deviation from the literal spec wording: the gate runs before `publishKind`, i.e. before ANY S3 write, not merely before `publishPackage`. Both gate inputs are local reads (parsed manifest + facade `package.json` in cwd), so a mismatch leaves the registry byte-identical. This is a strict superset of "abort before facade" and still honors kind-before-facade on the write path. + +**Pseudocode** + +`publish.ts` (at :93, replacing the lone publishPackage call): +```ts +// was: await registry.publishPackage(manifest, (file) => read(manifestRoot, file)); +await publishBlock( + registry, + manifest, + manifestRoot, // facade cwd — prepublishOnly runs here + async (file) => Buffer.from(await fs.promises.readFile(path.resolve(manifestRoot, file))), +); +// unchanged tail: stable-channel marker, published.json coords, refresh +``` + +`publish-block.ts` (NEW): +```ts +export async function publishBlock(registry, manifest, facadeDir, fileReader) { + // 1. resolve both refs (facade's direct kind dep read inside resolve-refs) + const modelKindRef = readModelCompiledKindRef(manifest); // components.model field + const facadeKindDep = readFacadeKindDependency(facadeDir); // facade package.json + + // 2. pure gate — hard-fail before ANY I/O + checkKindVersionMatch(modelKindRef, facadeKindDep); // throws KindVersionMismatchError + + // 3. kind FIRST — idempotent S3 kinds/ tree (source-hash guard inside publishKind) + await registry.publishKind(buildKindManifest(manifest), fileReader); + + // 4. facade — unchanged behavior + await registry.publishPackage(manifest, fileReader); +} +``` + +`version-match.ts` (NEW): +```ts +export class KindVersionMismatchError extends Error {} + +// reuse the SINGLE {name}@{version} codec from block_kind_ref.ts (§3) — do not fork it. +// (that shared parseKindRef must guard a malformed ref: lastIndexOf("@") <= 0 -> throw.) +import { parseKindRef } from "@milaboratories/pl-model-common"; // "{name}@X.Y.Z" -> { name, version } + +export function checkKindVersionMatch(modelKindRef, facadeKindDep) { + const m = parseKindRef(modelKindRef); + const f = parseKindRef(facadeKindDep); + if (m.name !== f.name || m.version !== f.version) // exact match, no semver range + throw new KindVersionMismatchError( + `Kind version mismatch: model compiled against ${modelKindRef}, ` + + `facade declares ${facadeKindDep}. Rebuild the model against the declared kind.`, + ); + // no soft path, no return value — success is "did not throw" +} +``` + +`resolve-refs.ts` (NEW — reads the facade's direct kind dependency): +```ts +export function readModelCompiledKindRef(manifest) { + const ref = manifest.description.components.model?.kindRef; // field from record-kind-ref concern + if (!ref) throw new Error("model component carries no compiled-against kind ref"); + return ref; // "{name}@X.Y.Z" +} + +export function readFacadeKindDependency(facadeDir) { + const pkg = JSON.parse(read(path.join(facadeDir, "package.json"))); + // A-0053: the facade depends on the kind DIRECTLY (kind is a fourth block component), + // so read the direct dependency — no transitive-through-model fallback. + const dep = pkg.dependencies?.[KIND_PACKAGE_NAME]; + if (!dep) throw new Error("facade declares no kind dependency"); + return `${KIND_PACKAGE_NAME}@${normalizeRange(dep)}`; +} +``` + +`registry.ts` `publishKind` (DEPENDENCY — shown for the contract this concern relies on): +```ts +public async publishKind(kindManifest, fileReader) { + const prefix = kindContentPrefix(kindManifest.id); // kinds/{org}/{name}/{version}/ + const existing = await this.storage.getFile(`${prefix}/${ManifestFileName}`); + if (existing !== undefined) { + const prev = parse(existing); + if (prev.sourceHash === kindManifest.sourceHash) return; // idempotent no-op + throw new Error(`Immutable kind version republished with different content: ${prefix}`); + } + // ... content-first upload with per-file sha256 verify (copy of publishPackage:480-495) + await this.storage.putFile(`${prefix}/${ManifestFileName}`, ...); // commit-marker LAST + await this.marchChanged(/* kinds-tree id */); // reconcile ticket +} +``` + +**Why this over the alternatives** — Rejected Proposal 1 (inline into `cmd.action`) on two counts. Testability: it claims coverage via the `publish.test.ts` harness, but that forces the load-bearing version comparison through a full commander build + `mkdtemp` + a synthesized facade `package.json` — the pure `checkKindVersionMatch` is a far cleaner and cheaper test target, and the comparison is exactly the part most worth isolating. Change-locality: Proposal 1's `resolveFacadeKindDep` lives inside the action, whereas Proposal 2 keeps the facade-dep read in one isolated function — so with A-0053 settling that read as a **direct** facade dependency (and for any later tweak to it), the gate and orchestrator do not move. Proposal 1's only genuine win — fewer files — is marginal: the net-new logic (gate, resolver, orchestrator call) is nearly identical LOC either way; Proposal 2 just files it behind seams. Blast radius is equivalent for the risky part: both leave `publishPackage` untouched, both delegate `publishKind`/source-hash/`hashSourceTree`/`kinds/` path helpers to the S3-kinds concern. On correctness both are equal — linear `await` ordering gives kind-before-facade, a plain `throw` gives hard-fail, purity gives idempotency for the gate. The tie-breakers (isolable pure gate, single isolated facade-dep reader, non-command forward-compat seam matching the spec's deferred phases) all favor Proposal 2. + +**Risks & open** +- **Q-0004 (facade↔kind direct dependency) — RESOLVED (A-0053, "the tetrad").** The kind is a fourth block component, and the published facade depends on the kind **directly** — a direct entry in the facade package's `dependencies`, not a transitive resolution through `model/package.json`. `readFacadeKindDependency` reads that direct dep (isolated in `resolve-refs.ts`), and the version-match compares it against the model's recorded kind reference. (The facade's `dependencies` was empty in the pre-decision blocks; the structurer now wires the kind dep in — see #2.) +- **Hard dependency on the record-kind-ref concern (#3).** `readModelCompiledKindRef` reads `manifest.description.components.model.kindRef`, a field that does not exist yet (added by the build-model.ts / build_dist.ts record-kind-ref concern). This concern cannot land until that field is present; the gate has nothing to read otherwise. +- **Hard dependency on the S3-kinds concern (#5).** `registry.publishKind`, the `kinds/` path helpers, the source-hash guard, and `hashSourceTree` are owned there. This concern owns only: (a) kind-before-facade call order, (b) the gate throwing before any write, (c) plain hard-fail, (d) idempotency delegated to `publishKind`'s guard. +- **Q-0005 (kind build mechanism) — RESOLVED (A-0052).** `sourceHash` is a deterministic content hash over the kind's sorted `src/` tree, computed by a single producer at build time — this is what `hashSourceTree` folds over. npm publish of the kind rides `pnpm -r publish` topological order over the workspace (a new workspace package), NOT block-tools — block-tools owns only the S3 `kinds/` tree side. The source-hash input is now pinned; the gate and orchestrator are unaffected either way. +- **Facade same-version overwrite remains unguarded.** `publishPackage:474-503` still does plain `PutObject` with no immutability guard. Only the `kinds/` tree gets the source-hash guard here; a reader expecting full block immutability from this concern will be surprised. Out of scope, flagged. +- **Exact-match vs. range.** `checkKindVersionMatch` compares pinned `{name}@X.Y.Z` refs. In the workspace/monorepo case the facade→kind dep is a `workspace:*` (or `workspace:^`, etc.) spec; `readFacadeKindDependency` resolves it to the kind package's concrete `package.json` version before the gate, so the comparison is exact-equality on concrete versions. A raw semver range would likewise be normalized/resolved to a concrete version first — otherwise a legitimate range would spuriously hard-fail. + +--- + +## 5. Registry Projection — `kinds/` Tree + `overview.json` via the Reconciler + +**Entry points** — grounded path:line, what to modify or add at each. + +- `core/platforma/tools/block-tools/src/v2/registry/registry.ts:474` — NEW `publishKind(kindManifest, fileReader)`, cloned from `publishPackage` (474-503): per-file size + sha256 verify (480-495), upload files, write `kind.d.ts`, write `kinds/{org}/{name}/{version}/manifest.json` LAST as the commit marker (mirrors 498-500). ADD a source-hash guard before writing (net-new — no same-version overwrite guard exists today). DO NOT call `marchChanged` (387). +- `core/platforma/tools/block-tools/src/v2/registry/registry.ts:247` — inside the existing `newVersions` build (both required inputs already in hand: parsed manifest at 249, channels at 239-245), read `description.kind` and, when present, push `{blockId, version, kindVersion, channels}` into a `touchedKinds` accumulator keyed by `kindOverviewPath`. Zero extra manifest reads. +- `core/platforma/tools/block-tools/src/v2/registry/registry.ts:162-172` — in the force branch only, after the existing v2/ block scan, LIST `kinds/*/*/overview.json` and seed each into `touchedKinds` with an empty entry set. The block scan already re-enumerates every kind ref (refs live *inside* block manifests), so this LIST exists solely to reset kinds orphaned by migration/removal. +- `core/platforma/tools/block-tools/src/v2/registry/registry.ts:316` — NEW post-loop step (before the global-overview write at 319): for each touched kind, RMW `kinds/{org}/{name}/overview.json` — load existing (skip load in force mode), filter out the `(blockId, version)` entries updated this pass, re-add fresh ones bucketed by declared kind version, recompute `{stable, any}` per kind version by mirroring the `latestByChannel` + derived `AnyChannel` computation (301-315), write back (delete when the result is empty). +- `core/platforma/tools/block-tools/src/v2/registry/schema_kinds.ts` (NEW) — `KindsPrefix='kinds/'`, `kindContentPrefix(org,name,version)`, `kindOverviewPath(org,name)`, `npmNameToKindPath` (strip trailing `.kind`, split dotted name → org/name), plus zod `KindManifest` (files + `sourceHash` + `firstUploadTimestamp`, `.passthrough()`) and `KindOverview` (kind versions × implementing block versions grouped `{stable, any}`, `.passthrough()`). Separate file, not sprinkled into `schema_public.ts:11/32`. +- `core/platforma/lib/model/middle-layer/src/block_meta/block_description.ts:36` — add optional `kind: KindRef.optional()` to `CreateBlockPackDescriptionSchema` so the read at `registry.ts:249` is typed. `.passthrough()` already carries it; explicit declaration is the seam the reconciler depends on. Optional ⇒ kind-less blocks unaffected. +- `core/platforma/lib/model/middle-layer/src/block_registry/overview.ts:27` — reuse `AnyChannel`/`StableChannel`/`VersionWithChannels`; define `KindRef {name, version}`. No new channel names. +- `core/platforma/tools/block-tools/src/v2/registry/registry_reader.ts:66` — NEW `getKindOverview(kindNpmName)`: single read of `kinds/{org}/{name}/overview.json` (one-file, no-LIST), then client-side semver resolution (newest kind version satisfying the specifier → newest implementing block on stable-or-any). NOTE: `relativeReader` is rooted at `MainPrefix='v2/'` (74); `kinds/` is a sibling, so this needs an absolute read or a second kinds-rooted reader — verify at implementation time. +- `core/platforma/tools/block-tools/src/cmd/publish.ts:88-113` — wire a kind-first publish step (construct registry, `publishKind`) ahead of the facade block publish. Kinds take no channel, so no `addPackageToChannel` call. (This is the same wiring concern #4 owns; the two concerns co-design the `publishKind` contract.) + +**Chosen path** — Hybrid, taking Proposal 2 ("Dedicated kinds/ module") as the base with one hardening decision made explicit. + +Both proposals converge on the same load-bearing mechanism, which I accept: the projection is derived from block manifests inside the *single existing* `updateRegistry` pass, via read-modify-write against each touched `kinds/{org}/{name}/overview.json` — no second reconciler, no `_updates_kinds`, no implementations marker tree. Normal mode is incremental-additive; force mode is the full-consistency backstop. This inherits the exact normal-incremental / force-full contract the package overview already runs on (222-265 for RMW, 301-315 for grouping). + +I pick Proposal 2 over Proposal 1 on two decisive points and one soft one: + +1. **Force mode as a *true* reconciler (correctness).** Proposal 1 says force mode "starts each touched kind empty" and relies on the block rescan to rebuild. But a kind that has lost *all* implementers is never touched by any block and was never seeded — so its stale `overview.json` survives even a force run. Proposal 2's force-mode `kinds/` LIST seeds every existing kind overview empty, so orphaned kinds are rewritten/deleted. That closes the one hole in the full-rebuild path for the cost of a single force-only LIST. + +2. **Correcting the map's KindManifestPattern claim (blast radius).** The code map lists a `KindManifestPattern` force-scan as required. Proposal 2 correctly observes it is *not* — kind refs live inside block manifests, so the pre-existing v2/ scan (162-172) already enumerates every ref. Dropping that pattern removes surface. I adopt this. + +3. **`schema_kinds.ts` (fit / forward-compat).** A genuinely new registry tree, plus the deferred template-engine/sandbox phases, justify a cohesive module over mirroring into `schema_public.ts`. Soft call; low stakes either way. + +Everything else is common to both and accepted verbatim: `publishKind` drops no ticket (projection rides the block ticket via `marchChanged`, honoring "no `_updates_kinds`"); the source-hash guard is net-new; the `kind` field is optional; backend has no part. + +**Pseudocode** + +`publishKind` (registry.ts:474, cloned from `publishPackage`): +```ts +async publishKind(kindManifest, fileReader) { + const { org, name } = npmNameToKindPath(kindManifest.name); // strip .kind, split dotted + const base = kindContentPrefix(org, name, kindManifest.version); + + // NET-NEW source-hash guard (no same-version overwrite guard exists in publishPackage) + const existing = await this.tryReadKindManifest(base); // read manifest.json if present + if (existing) { + if (existing.sourceHash === kindManifest.sourceHash) return; // no-op + throw new Error(`kind ${name}@${version} immutability violation`); // hard-fail + } + // absent → proceed + + for (const f of kindManifest.files) { // e.g. kind.d.ts + const bytes = await fileReader(f.name); + assert(bytes.length === f.size); + assert(calculateSha256(bytes) === f.sha256); // per-file, as publishPackage + await this.upload(`${base}/${f.name}`, bytes); + } + // manifest LAST = commit marker; stamp firstUploadTimestamp when absent + await this.upload(`${base}/manifest.json`, + KindManifest.parse({ ...kindManifest, firstUploadTimestamp: Date.now() })); + // NO marchChanged — projection is derived from BLOCK manifests, rides the block ticket +} +``` + +Accumulate inside the existing pass (registry.ts:247, within `newVersions` build): +```ts +const desc = BlockPackManifest.parse(manifestBytes).description; // line 249, already parsed +const channels = listedChannels; // 239-245, already listed +if (desc.kind) { + const { org, name } = npmNameToKindPath(desc.kind.name); + const path = kindOverviewPath(org, name); + const acc = touchedKinds.get(path) ?? { touched: new Set(), add: [] }; + acc.touched.add(`${blockId}@${version}`); + acc.add.push({ blockId, version, kindVersion: desc.kind.version, channels }); + touchedKinds.set(path, acc); +} +``` + +Force-only orphan seed (registry.ts:162-172, force branch, after the v2/ block scan): +```ts +if (force) { + for (const p of await this.list(`${KindsPrefix}`)) { // kinds/*/*/overview.json + if (isKindOverviewPath(p)) touchedKinds.set(p, { touched: null, add: [] }); // seed empty + } +} +``` + +Post-loop write (registry.ts:316, before global-overview write at 319): +```ts +for (const [path, acc] of touchedKinds) { + const current = force ? emptyKindOverview() : (await this.readKindOverview(path) ?? emptyKindOverview()); + // RMW, mirroring per-package filter+re-add (222-265) + const kept = acc.touched === null + ? [] // force: full rebuild from scan + : current.entries.filter(e => !acc.touched.has(`${e.blockId}@${e.version}`)); + const merged = [...kept, ...acc.add]; + + // bucket by kind version, then {stable, any} per bucket mirroring 301-315 + const byKindVersion = groupBy(merged, e => e.kindVersion); + const kindVersions = Object.entries(byKindVersion) + .map(([kv, impls]) => ({ + kindVersion: kv, + latestByChannel: newestPerChannel(impls), // StableChannel slot + [AnyChannel]: newestRegardlessOfChannel(impls), // derived any slot + })) + .sort((a, b) => compareSemver(a.kindVersion, b.kindVersion)); + + if (kindVersions.length === 0) await this.delete(path); // orphaned → remove file + else await this.upload(path, KindOverview.parse({ kindVersions })); +} +``` + +`getKindOverview` (registry_reader.ts:66): +```ts +async getKindOverview(kindNpmName, kindSpecifier) { + const { org, name } = npmNameToKindPath(kindNpmName); + const ov = await this.readAbsolute(kindOverviewPath(org, name)); // sibling of v2/, not via relativeReader + const kv = newestSatisfying(ov.kindVersions, kindSpecifier); // client-side semver + return kv?.[AnyChannel] ?? kv?.latestByChannel[StableChannel]; // newest impl, stable-or-any +} +``` + +**Why this over the alternatives** + +- **Rejected Proposal 1's force-empty-touched-only rebuild** — it leaves fully-orphaned kind overviews stale even after a force run, because an orphaned kind is touched by no block and was never seeded. The force-mode `kinds/` LIST (Proposal 2) is the minimal fix and turns force into a real reconciler. Cost: one LIST, force-path only. +- **Rejected the map's `KindManifestPattern` force scan** — unnecessary surface. Kind refs live in block manifests; the existing v2/ scan enumerates them for free. Adding a parallel kind-manifest scan would duplicate work and invite the two enumerations to disagree. +- **Rejected a separate kind reconciler / `_updates_kinds` ticket prefix** — the spec forbids it and it is genuinely unneeded: the projection's only inputs (kind ref + channels) are already in hand mid-pass, and block publish/channel ops already drop the ticket via `marchChanged`. Kind content publish stays inert (no ticket) by design. +- **Rejected recomputing the source hash in `publishKind`** — the registry lacks `src/` at publish time; the guard trusts the build-time sorted-tree digest carried on the manifest. This is a dependency, not a defect, but it caps the guarantee (see risks). +- **Chose `schema_kinds.ts` over mirroring into `schema_public.ts`** — a new tree plus deferred phases warrant a cohesive import surface. Reviewers expecting the in-place mirror may push back; the deviation is deliberate and cheap to reverse. + +**Risks & open** + +- **RMW correctness rests on block-version immutability of the kind ref.** Blocks have no same-version overwrite guard today (`publishPackage` 474-503). A same-version block republish that changes or drops its kind ref orphans the old kind's entry until a force reconcile. Mitigation adopted: force-mode LIST + full rebuild self-heals. Stronger fix (out of scope here, sibling concern): add a same-version block guard mirroring the new `publishKind` sourceHash guard. +- **Normal-mode staleness window** for kindA→kindB switches / dropped refs is inherent to no-LIST incremental derivation. How often the force backstop runs is governed by **Q-0008 (overview-refresh trigger, implementation.md:354)** — settle with the operator; it directly sets the staleness bound. +- **Source-hash guard is well-defined under A-0052 (Q-0005).** `sourceHash` is a deterministic content hash over the sorted `src/` tree, produced once at build time (explicitly NOT the per-file `calculateSha256` at util.ts:28). With that input pinned, the guard's absent / equal / differ decision is well-defined. +- **Kind is invisible until it has an implementer** — `publishKind` drops no ticket, so `getKindOverview` 404s (no `overview.json`) for a kind with zero implementing blocks. Correct per the block-derived projection model, but confirm it is intended UX. +- **Reader rooting unverified** — `registry_reader` is rooted at `MainPrefix='v2/'` (74); `kinds/` is a sibling. `getKindOverview` needs an absolute read or a second kinds-rooted reader. Quick check at implementation time. +- **Empty vs delete on orphan** — pseudocode deletes the overview when no implementers remain (cleanest for the no-LIST reader: 404 ⇒ unresolvable). Writing an empty file is the alternative. Low-stakes; confirm preference. +- **`npmNameToKindPath` convention** (strip trailing `.kind`, split dotted name → org/name) is net-new and unverified; a wrong split silently misfiles both content and projection. Pin it against the actual kind npm-naming convention (implementation.md:131) before shipping. + +--- + +## 6. Resolution — Middle-Layer Kind → Concrete Block + +Verdict: **Proposal 2 wins (pure resolver core), with one graft from Proposal 1** — keep P2's IO-free `kind_resolver.ts` module, but lift the selector→range translation into a small named `selectorToRange` helper (P1) rather than folding it silently into `resolveKindVersion`, because the `@`→exact translation and the pre-1.0 caret / prerelease edges each deserve a testable seam. The two proposals are 90% identical (same files, same reuse, same output spec, same "distinct error cases"); the only real fork is *where the net-new version-math lives*. Since the code map flags this concern as **net-new, correctness-critical logic** (there is no existing range resolver to extend), isolating it as a pure, literal-testable unit is the deciding factor. + +### Entry points — grounded path:line, action + +- `core/platforma/tools/block-tools/src/v2/registry/schema_public.ts:11` — **ADD** `KindsPrefix = "kinds/"` beside `MainPrefix`. +- `schema_public.ts:22,49` — **ADD** `kindOverviewPath({org,name})` = `kinds/{org}/{name}/overview.json` beside `packageOverviewPathInsideV2`. Reuse `npmNameToKindPath(npmName)` (npm-name → `{org,name}`, strips a trailing `.kind`, implementation.md:131) — defined **once** in §5's `schema_kinds.ts`; do not redefine it here, and do not conflate it with the `{name}@{version}` reference codec `parseKindRef` (§3). +- `schema_public.ts:77-181` — **ADD** `KindOverviewRawSchema` + `parseKindOverviewReg` + `normalizeKindOverviewEntry`, cloning the `.passthrough()` + post-parse-normalize pattern of `GlobalOverviewEntryRawSchema`/`parseGlobalOverviewReg`. Derive the `any` channel slot once at parse time (union of stable+unstable per kind version). +- `core/platforma/tools/block-tools/src/v2/registry/kind_resolver.ts:NEW` — **ADD** pure, IO-free module: `selectorToRange(selector)`, `resolveKindVersion(overview, range)` (`semver.maxSatisfying`), `pickImplementingBlock(entry, {allowUnstable})`. Returns a discriminated `Result` union (see error cases below). No FolderReader, no cache, no `semver` beyond range math — everything a caller can feed with a literal. +- `registry_reader.ts:116` (after `listBlockPacks`) — **ADD** `readKindOverview(ref, {signal})` mirroring `listBlockPacks`' exact IO shape (verified: `readFile → Buffer→JSON.parse → parse*Reg`, retry `Retry2TimesWithDelay`, list-cache + stale-on-error at 124-183). Reuse the same cache/retry treatment. +- `registry_reader.ts:215` (beside `getSpecificOverview`) — **ADD** `resolveKind(ref, {allowUnstable})` = `readKindOverview` → `kind_resolver` → return the **byte-identical** `from-registry-v2` spec shape at lines 232-237 (`{type:"from-registry-v2", id, registryUrl: this.registryReader.rootUrl.toString(), channel}`). Do **not** reuse `inferUpdateSuggestions` (tier logic, not range satisfaction). +- `core/platforma/lib/node/pl-middle-layer/src/block_registry/registry.ts:290` — **ADD** facade `resolveKind(registryId, ref, {allowUnstable})` cloned from `getOverview` (resolve `registryId` → assert `remote-v2` → `v2Provider.getRegistry(url).resolveKind(...)`). +- `registry-v2-provider.ts:9` — **NO CHANGE** (kind + impls co-reside per decisions.md:69; cached reader serves both). +- `mutator/block-pack/block_pack.ts:123` — **NO CHANGE** (`from-registry-v2` output is already what `getComponents`/`prepareBlockPack` consume). + +### Chosen path (prose) + +A kind reference `{name}@{selector}` is resolved with **one projection read + client-side semver, then the existing v2/ fetch** — the spec's literal recipe. `readKindOverview` performs exactly one `readFile` against `kinds/{org}/{name}/overview.json`, cache/retry-wrapped identically to the block projection read. The parsed, normalized overview (kind versions × implementing blocks grouped by channel, with `any` derived) is handed to the pure `kind_resolver`. There, `selectorToRange` maps `@X.Y.Z`→`=X.Y.Z`, `~X.Y.Z`→`~X.Y.Z`, `^X.Y.Z`→`^X.Y.Z` (valid semver ranges — the spec redefines tier *meaning*, not range arithmetic, implementation.md:141); `resolveKindVersion` picks the newest in-range kind version via `semver.maxSatisfying`; `pickImplementingBlock` selects the target channel (`StableChannel` default, `AnyChannel` when the apply-time `allowUnstable` flag is set) and takes the newest block version there. The result is a `BlockPackId` wrapped into a `from-registry-v2` spec, which `prepareBlockPack` materializes through the unchanged add-block path via `getComponents`. No cross-registry intersection: kind and its blocks share one registry, so one cached reader answers both reads. + +Logic lives in `@platforma-sdk/block-tools`; only the thin facade lands in `pl-middle-layer` — consistent with the no-backend decision (decisions.md:15). + +### Pseudocode + +```ts +// schema_public.ts +export const KindsPrefix = "kinds/"; +export const kindOverviewPath = (loc: { org: string; name: string }) => + `${KindsPrefix}${loc.org}/${loc.name}/overview.json`; +// npm-name -> {org,name} is npmNameToKindPath, defined ONCE in schema_kinds.ts (§5). Import it. +// It is NOT parseKindRef: parseKindRef parses the "{name}@{version}" reference string (§3). +import { npmNameToKindPath } from "./schema_kinds"; // "@org/x.y.kind" -> { org, name } +export const KindOverviewRawSchema = z.object({ + versions: z.array(z.object({ + version: z.string(), + implementingBlocks: z.object({ + // channel -> newest block id per channel; "any" DERIVED at normalize + stable: z.array(BlockRef).optional(), + // ...other channels passthrough + }).passthrough(), + }).passthrough()), +}).passthrough(); +export function parseKindOverviewReg(raw): KindOverview { /* parse + normalize + derive `any` */ } +``` + +```ts +// kind_resolver.ts (PURE — no IO, unit-tested with literals) +export type KindResolution = + | { ok: true; blockId: BlockPackId; channel: string } + | { ok: false; reason: "no-matching-kind-version" } // maxSatisfying === null + | { ok: false; reason: "no-implementation" } // version exists, zero impls + | { ok: false; reason: "no-stable-implementation" }; // impls exist, none stable, !allowUnstable + +export function selectorToRange(sel: Selector): string { + switch (sel.op) { + case "exact": return `=${sel.version}`; // @ -> exact + case "patch": return `~${sel.version}`; // ~ -> patch floor + case "minor": return `^${sel.version}`; // ^ -> minor floor + } +} + +export function resolveKind(ov: KindOverview, sel: Selector, opt: {allowUnstable: boolean}): KindResolution { + const versions = ov.versions.map(v => v.version); + const picked = semver.maxSatisfying(versions, selectorToRange(sel)); // prerelease policy: see risks + if (!picked) return { ok: false, reason: "no-matching-kind-version" }; + const entry = ov.versions.find(v => v.version === picked)!; + const channel = opt.allowUnstable ? AnyChannel : StableChannel; + const blocks = entry.implementingBlocks[channel] ?? []; + if (channel === StableChannel && blocks.length === 0) { + return (entry.implementingBlocks[AnyChannel]?.length ?? 0) > 0 + ? { ok: false, reason: "no-stable-implementation" } + : { ok: false, reason: "no-implementation" }; + } + if (blocks.length === 0) return { ok: false, reason: "no-implementation" }; + const newest = blocks.reduce((a, b) => semver.gt(b.version, a.version) ? b : a); + return { ok: true, blockId: newest.id, channel }; +} +``` + +```ts +// registry_reader.ts — thin IO adapter +public async readKindOverview(ref, {signal} = {}) { + // mirror listBlockPacks 124-183: cache-check → retry(async () => parseKindOverviewReg( + // JSON.parse(Buffer.from(await v2RootFolderReader.readFile(kindOverviewPath(ref), {signal}))))) → cache → stale-on-error +} +public async resolveKind(ref, {allowUnstable}) { + const ov = await this.readKindOverview(ref); + const r = resolveKind(ov, ref.selector, {allowUnstable}); + if (!r.ok) throw new KindResolutionError(r.reason, ref); // typed; caller maps to spec errors + return { // === getSpecificOverview shape (232-237) + type: "from-registry-v2", + id: r.blockId, + registryUrl: this.registryReader.rootUrl.toString(), + channel: r.channel, + }; +} +``` + +```ts +// registry.ts — facade, cloned from getOverview 290-303 +public async resolveKind(registryId, ref, {allowUnstable}) { + const reg = this.getRegistryEntry(registryId).spec; + if (reg.type !== "remote-v2") throw new Error("kind resolution requires remote-v2 registry"); + return this.v2Provider.getRegistry(reg.url).resolveKind(ref, {allowUnstable}); +} +``` + +### Why this over the alternatives + +- **Rejected P1 (inline methods on `RegistryV2Reader`)** on *fit + testability*, not blast radius (both touch the same files). The version-math + channel-selection is the net-new, error-prone core (`^0.x` semver quirk, prerelease inclusion, `@`→exact translation, three empty outcomes). P1 buries it inside a class method that needs FolderReader/LRUCache mocks to exercise; P2 makes it a pure function tested with a KindOverview literal — the highest-value test surface for genuinely new logic. P1's own con concedes `RegistryV2Reader` is already a large multi-responsibility class; adding resolution logic muddies its reader identity further. The verified codebase convention reinforces P2: `inferUpdateSuggestions` is a **top-level pure function**, not a method — pure version-math helpers already live at module scope here. +- **Rejected P1's "smallest surface" framing** as decisive: one new file + a couple exports is trivial and fully reversible, and both proposals equally expand the `@platforma-sdk/block-tools` public API (both export `readKindOverview`/`resolveKind`). The surface delta is noise. +- **Rejected P2's over-claim** that the template-engine batch loop (implementation.md:216) is a P2-only win — the LRU/list-cache on `readKindOverview` already dedupes repeated reads, so P1 would batch acceptably too. I did not weight this. P2 still wins on the pure-core testability alone. +- **Grafted from P1**: the named `selectorToRange` helper (P2 folded it into `resolveKindVersion`). Naming it isolates the two correctness edges below. +- **Added beyond both**: a *third* error outcome `no-matching-kind-version` (selector satisfies zero kind versions — `maxSatisfying` returns `null`). Both proposals modeled only the two block-implementation empty cases (implementation.md:145) and silently assumed a kind version always resolves. It may not. + +### Risks & open + +- **Pre-1.0 caret/tilde quirk (correctness, not raised by either proposal):** `semver` treats `^0.2.3` as `>=0.2.3 <0.3.0` (caret behaves like tilde below 1.0) and `~0.2.3` as `>=0.2.3 <0.3.0` as well — so for `0.x` kinds, `^` and `~` collapse to the same range. If the spec's "minor floats" tier must span `0.2 → 0.3` for pre-1.0 kinds, stock semver ranges will **not** deliver it. **Verify against the spec whether kinds are guaranteed `>=1.0.0`;** if not, `selectorToRange` needs an explicit range for the `0.x` minor case rather than `^`. +- **Prerelease policy (open):** `semver.maxSatisfying` excludes prereleases (e.g. `1.2.0-rc.1`) from `^`/`~` ranges unless `{includePrerelease:true}`. Decide whether prerelease kind versions are eligible; wire the option in `selectorToRange`/`resolveKindVersion` accordingly. Default (exclude) is the safe assumption pending confirmation. +- **Schema authored blind (co-design):** the `KindOverview` shape is *produced* by the block-overview reconciler/writer (concern #5, `tools/block-tools/src/v2/registry/registry.ts` ~line 250). `.passthrough()`+normalize tolerates additive drift but **not field renames**. Lock the reader schema against the reconciler's emitted JSON (or the spec's canonical example at implementation.md:116-131) before finalizing field names; reader and writer must stay in lockstep. +- **Q-0005 (kind build mechanism) — RESOLVED (A-0052):** the projection this concern reads exists once kinds are built/registered and the reconciler emits `kinds/{org}/{name}/overview.json`. The build mechanism is now settled (`defineBlockKind({ name, version })` takes a `{ name, version }` argument imported from the kind's `package.json`, deterministic `src/`-tree `sourceHash`), so the `KindOverview` schema and the channel-grouping assumption in `pickImplementingBlock` rest on a fixed shape. This resolver stays a pure *consumer* of that shape; its correctness now depends only on staying in lockstep with the reconciler (co-design, see above), not on any open build question. +- **Deferred-phase reuse (unverified benefit):** the sandbox phase reusing the pure core unchanged is asserted, not proven; treat as a nice-to-have, not a design constraint. + +--- + +## Cross-Cutting Concerns + +### Naming and paths + +- **New SDK package:** `@platforma-sdk/block-kind` at `core/platforma/sdk/block-kind/` (sibling of `sdk/test`, `sdk/model`, `sdk/eslint-config`). Registered in `pnpm-workspace.yaml:62`. +- **Shared reference type:** `BlockKindReference` + `formatKindRef`/`parseKindRef` codecs live once in `core/platforma/lib/model/common/src/bmodel/block_kind_ref.ts` and are imported by the model wiring (#3), the publish gate (#4), the reconciler (#5), and the resolver (#6). This is the single place that decides whether the reference's name segment is org-qualified — do not fork it. +- **On-wire reference form:** `{name}@X.Y.Z` string (spec `decisions.md:23`, `implementation.md:181`), recorded at the **container level** of `model.json` (beside `code`) and surfaced into the block manifest (`v2/{org}/{name}/{version}/manifest.json`). +- **Registry tree:** `kinds/{org}/{name}/` parallel to `v2/` in the **same** registry (`implementation.md:113-121`). Per-version content at `kinds/{org}/{name}/{version}/` (`manifest.json` + `kind.d.ts`); the reconciler-maintained projection at `kinds/{org}/{name}/overview.json`. +- **npm-name → path convention:** `@platforma-open/milaboratories.mixcr-clonotyping.kind` → dotted name `milaboratories.mixcr-clonotyping.kind`; first segment = org, middle = block name, strip trailing `.kind` → `kinds/milaboratories/mixcr-clonotyping/{version}/` (`implementation.md:131`). Encapsulated in `npmNameToKindPath` (distinct from the `{name}@{version}` reference codec `parseKindRef`); **unverified** — pin before shipping. +- **Build target name:** `block-kind` in the ts-builder `TargetType` registry (parallel to `block-facade`); config files `rolldown.block-kind.config.js` / `tsconfig.block-kind.json`. +- **Field-name mismatch — RESOLVED by elimination (A-0052 / Q-0005):** the compiled kind descriptor carries no separate `organization`/`org` field — only the full npm `name` + `version`. The registry `{org,name}` path is derived from that npm name via the existing `parsePackageName` parser (`core/platforma/tools/block-tools/src/v2/source_package.ts`), so there is no `organization`-vs-`org` naming to reconcile. + +### Versioning of `@platforma-sdk/block-kind` + +The spec requires the package be **additive-only** — new exports may be added, existing ones never change or disappear — which is what lets a kind published long ago keep compiling against it (`implementation.md:39`). The path enforces this structurally via the `kindSchema:"v1"` discriminated-union envelope (#1): future template-engine / sandbox phases add a `CompiledBlockKindV2` union arm and consumers narrow on `kindSchema`, so v1 kinds keep compiling untouched. The package follows the standard changesets-gated `pnpm -r publish` release path like every other SDK package; there is no bespoke versioning machinery. The kind *content* published to the S3 `kinds/` tree is a separate, immutable, source-hash-guarded artifact — do not conflate the npm package version of `@platforma-sdk/block-kind` (the tooling) with a kind's own `{name}@X.Y.Z` version (the contract). + +### V1 → V3 block-model migration prerequisite + +A block must be on **`BlockModelV3` + `DataModelBuilder`** to carry a kind: the kind object is threaded through `new DataModelBuilder(kind)` and `BlockModelV3.create({ dataModel, kind })`, and the init lambda is typed against the kind's `BlockParams` (`decisions.md:55`, `implementation.md:63`). Legacy `BlockModel` (V1) blocks (`block_model_legacy.ts`) have no such surface and **must migrate to V3 first**. This is a hard prerequisite that sits *outside* the kind subsystem — the kind wiring (#3) does not build the V1→V3 migration. Making `DataModelBuilder`'s constructor kind **required** (#3) is a breaking change to every existing V3 block simultaneously; the two migration-sequencing options are (a) required-kind + one codemod sweep across all V3 blocks, or (b) a transition window with `constructor(kind?)` and a fail-safe `undefined` reference (the reconciler simply can't project kind-less blocks yet, matching the `workflowCapabilities` fail-safe precedent). Choose the sequencing before landing #3. + +### Consistency with the mispec spec + +The path is grounded against `docs/text/work/projects/block-kind-and-templates/decisions.md` and `.../implementation.md`. Key alignments: + +- **TypeScript-only, no backend** (`decisions.md:15`, `implementation.md:7`) — every concern lands in the SDK / block-tools / middle-layer; `core/pl` is untouched. The resolver facade (#6) is the deepest the path reaches into the middle layer, and it is thin (`decisions.md:15`). +- **Kind is a fourth block component; the facade depends on it directly** (A-0053, reversing note A-0013's earlier v4.0.0 "recorded in the model, not a fourth component" conclusion) — #3 still bakes the reference at the `model.json` container level for the runtime/export path, but the facade's **direct** build-time dependency on the kind is what the publish-time version-match reads (#4's `resolve-refs.ts`), and the structurer discovers `kind/` alongside `model/`/`workflow/`/`ui/` (#2). +- **Kind-first publish, version-match gate, hard-fail** (`decisions.md:57-83`) — #4 sequences `checkKindVersionMatch` → `publishKind` → `publishPackage`, with the gate before *any* S3 write (a confirmed-acceptable strict superset of "abort before facade"). +- **Source-hash guard: one sha256 over the sorted `src/` tree, stored in the kind manifest, `absent→store / equal→no-op / differ→hard-fail`** (`decisions.md:73-83`, `implementation.md:273-283`) — computed at build (#2), enforced at publish (#4/#5). Explicitly **not** npm `dist.integrity`, `npm pack` bytes, or the Turbo hash. +- **Projection is derived from block manifests, single source of truth, no `_updates_kinds`, no kind-side markers** (`decisions.md:103-107`, `implementation.md:123-129`) — #5 rides the block ticket via `marchChanged`; `publishKind` drops no ticket. +- **Three version selectors with redefined-semver tiers** (`decisions.md:87-95`, `implementation.md:135-141`) — #6's `selectorToRange` maps `@/~/^` to exact/patch-floor/minor-floor; the spec redefines tier *meaning* (major=params-break, minor=behavior, patch=additive), not range arithmetic. +- **Resolution reads one file, client-side semver, stable-by-default / allow-unstable, distinct error cases** (`decisions.md:99`, `implementation.md:145`) — #6 reads one `overview.json`, resolves with `semver.maxSatisfying`, and models the empty cases as a discriminated result union (extended with a third `no-matching-kind-version` case). + +--- + +## Open Risks and Spec Open-Question Dependencies + +Two questions that gated this path when the draft was written are now **decided in the spec** and are no longer open: + +- **Q-0004 (facade↔kind direct dependency) — RESOLVED (decision A-0053, "the tetrad").** The kind is a fourth block component; the published facade depends on the kind **directly** (a direct entry in the facade package's `dependencies`), not transitively through the model. #4's `readFacadeKindDependency` (`resolve-refs.ts`) reads that direct dep and compares it against the model's recorded kind reference; #2's structurer rule (`block-package-json.ts`) is activated to discover `kind/` and wire/assert the kind build/check plus the `blockComponents` mapping. +- **Q-0005 (kind build mechanism & identity) — RESOLVED (decision A-0052).** `defineBlockKind({ name, version })` takes a `{ name, version }` argument; the kind's `src/index.ts` imports `{ name, version }` from its own `package.json` (`with { type: "json" }`) and passes them in, and the bundler (rolldown + rolldown-plugin-dts, `external: () => false`) inlines that JSON import — tree-shaken to the two strings — into the compiled `kind.js`, emitting a readable `kind.d.ts`. There is no build-time injection and no `define`. Identity carries no separate `organization`/`org` field — the registry `{org,name}` path is derived from the full npm name via the existing `parsePackageName` parser. `sourceHash` is a deterministic content hash over the sorted `src/` tree, produced once at build time (not the per-file `calculateSha256`), which makes the source-hash guard's absent/equal/differ decision well-defined. (This reverses note A-0013's earlier v4.0.0 "recorded in the model, not a fourth component" framing; the `model.json` reference is retained for the runtime/export path.) + +The remaining open questions the path's correctness is still gated on (`implementation.md:345-354`), ordered by how much of the path each blocks: + +- **Q-0008 (overview-refresh trigger)** — *sets the staleness bound on #5.* How the kind overview refresh is triggered in production (cron / on-publish / manual). The reconciler's normal mode is incremental-additive; kindA→kindB switches and dropped refs self-heal only on a force run, so the refresh cadence directly bounds the projection's staleness window. Settle with the operator. +- **Q-0007 (engine add-block API)** — *does not block this path; it is the entry point of the deferred template-engine document.* The resolver (#6) emits a `from-registry-v2` spec that the existing add-block path (`prepareBlockPack`/`getComponents`) consumes unchanged; how the fixed native YAML lambda then threads params and resolved references through to the init lambda is engine detail resolved in the template doc, not here. +- **Q-0009 (apply-time params validation)** — *deferred to the template document.* A block's init is compile-time typed against its kind, but a hand-authored YAML's `params` are untyped; what validates them at apply time is a template-engine concern, out of scope for the KIND subsystem. + +Standing cross-cutting risks independent of the open questions: + +- **Source-hash case convention** (`hashDirSync` lowercase vs `calculateSha256` uppercase) is a live correctness seam across #2/#4/#5; mitigated by `.toUpperCase()` at the manifest-write site. Any comparator on the publish side must use the same convention or the guard reports "different" on every run. +- **`npmNameToKindPath` / `parseKindRef` split convention** is net-new and unverified; a wrong split silently misfiles both content and projection (#5) and misroutes resolution reads (#6). Pin against the real kind npm-naming convention before shipping. +- **Reader rooting** — the v2 `registry_reader` is rooted at `MainPrefix='v2/'`; `kinds/` is a sibling, so the kind-overview read (#5/#6) needs an absolute read or a second kinds-rooted reader. Quick verification at implementation time. +- **Pre-1.0 semver quirk** (#6) — `^0.x` and `~0.x` collapse to the same range under stock semver; if kinds are not guaranteed `>=1.0.0`, `selectorToRange` needs an explicit range for the `0.x` minor case. +- **Facade same-version overwrite remains unguarded** — only the `kinds/` tree gets the source-hash immutability guard in iteration 1; full block immutability is out of scope and flagged. diff --git a/docs/block-kinds-templates/01-kind-and-lifecycle.md b/docs/block-kinds-templates/01-kind-and-lifecycle.md new file mode 100644 index 0000000000..edbd40bb7b --- /dev/null +++ b/docs/block-kinds-templates/01-kind-and-lifecycle.md @@ -0,0 +1,248 @@ +# Track 1 — Kind + Lifecycle (Preamble) + +**Status: preamble only.** Scope and open questions, not a plan. + +Authoritative design: the `docs/text/work/projects/block-kind-and-templates/` mispec +corpus. This document reflects the **PR #198 rework** ("kind publishes with the facade", +branch `feat/kind-publish-with-facade`, not yet on main), which replaced the earlier +read-back/address-verify and `implementations.json` model. Citations use **atom IDs** +(e.g. `A-0011`) because rendered line numbers shift. + +## Progress Tracker + +> **Where we are:** the kind machinery is largely **built and committed** — SDK +> descriptor, model wiring, reference codec, publish + version-match, registry +> projection + resolution, and structurer enforcement all exist. The prose below +> is written in target/"pseudocode" tense and predates that landing; read it as +> the _design rationale_, and this tracker as the _current state_. Remaining work +> is fixture migration, `sdk/block-kind` package hardening, and one open cadence +> question. +> +> **Status legend** — implementation: `[x]` done · `[~]` partial · `[ ]` not +> started/open. Design tags: `DECIDED`/`RESOLVED` (settled), `OPEN` (undecided), +> `VERIFY` (implemented but not confirmed in the last status pass). + +**Kind machinery (in scope)** + +- [x] **Kind = 4th component (tetrad), structurer-enforced** — `structure/rules/kind.ts`, `block-package-json.ts` mandatory-kind check (throws if `kindModules.length !== 1`). · DECIDED `Q-0004` (variant B) + - [~] **Facade↔kind link — design evolved, prose stale.** Shipped as a **direct facade dependency** (normally a `workspace:*` **devDep**, `.kind`-suffixed — `resolve-refs.ts:61` scans both dep maps) with the reference carried at top-level **`description.kind`** (`resolve-refs.ts:36`); there is no `block.components.kind` map, and `block_components.ts` was intentionally left unchanged. The preamble's "`block.components: { kind, model, workflow, ui }`" wording (below) no longer matches the code. · VERIFY (`A-0053`) +- [x] **Model wiring** — `DataModelBuilder({ kind })`, `BlockModelV3.create({ dataModel, kind })`, params-carrying `DataCreateFn`, container-level bake — `sdk/model/src/block_migrations.ts`, `block_model.ts`. +- [x] **Reference carrier** — `lib/model/common/src/bmodel/block_kind_ref.ts` (`formatKindRef`/`parseKindRef`), `container.kind`, `description.kind`, `modelKindReference` reader in `build_dist.ts`. +- [x] **Publish flow — kind-first + version-match gate** — `v2/publish-block.ts`, `v2/kind/version-match.ts`, `v2/kind/resolve-refs.ts`, `registry.publishKind`; wired in `cmd/publish.ts`. +- [x] **Source-hash guard** — `buildKindDist` hashes `kind/src/`; `publishKind` enforces immutability (absent→write / equal→no-op / differ→abort). +- [x] **Registry layout + resolution** — `v2/registry/schema_kinds.ts` (`npmNameToKindPath`, `overview.json` projection), `kind_resolver.ts` (`resolveKind`), middle-layer `resolveKind` facade (`pl-middle-layer/src/block_registry/registry.ts`). +- [x] **Version selectors `@`/`~`/`^`** — `kind_resolver.ts` (`parseSelector`/`selectorToRange`). +- [~] **Channels** (`stable` marker; unstable = its absence; `any` = derived newest) — reuses the existing registry channel mechanism; not re-confirmed in the last status pass. · VERIFY + +**Kind artifact (decided)** + +- [x] `BlockParams` is pinned twice: as a TS type (phantom `InferBlockParams`) and by the kind's + **mandatory** `parseTemplateParams` — `descriptor.ts`. A kind therefore ships runtime code; + zod is the default the workspace kinds use, not part of the contract. +- [x] Named export, no default — `defineBlockKind`. +- [x] Reference `{name}@{version}` derived; registry address never stored. +- [x] Per-version manifest = sourceHash + first-upload ts — `buildKindDist`. · RESOLVED `A-0052`/`Q-0005` (name+version imported from the kind's own `package.json`; rolldown inlines the JSON import) + +**`sdk/block-kind` package (hardening)** + +- [x] **Complete for v1** — `defineBlockKind`, `descriptor.ts` (`CompiledBlockKind`/`InferBlockParams`), type-level test; workspace-listed (`pnpm-workspace.yaml:63`). + - [~] **Deliberately diverges from impl-path §1 — confirm intent.** The package imports nothing from `@milaboratories/pl-model-common` (the descriptor is fully generic over `BlockParams`), so it does **not** re-export `PlRef`, keeps `pl-model-common` as a devDep, and builds with `--target node`. Impl-path §1 planned a `PlRef` re-export + runtime dep; since `PlRef` never enters the emitted `.d.ts`, the leaner shape is self-consistent — a defensible simplification, not an oversight, but worth confirming as intentional. · VERIFY + +**Fixture migration (`etc/blocks`)** + +- [x] Structurer seed is a non-building `NEEDS_BLOCK_PARAMS` sentinel that gates unmigrated blocks — `templates/static/kind/src/index.ts`. +- [x] Migrated with real params: `sum-numbers` (`{ sources?: PlRef[] }`), `table-test` (`{ label: string }`). +- [x] Not migrated: `enter-numbers`, `filter-column-test`, `model-test`, `pool-explorer`, `ui-examples` (their `kind/` dirs hold only stray untracked build artifacts). + +**Open questions & follow-ups** + +- [x] `Q-0004` — DECIDED (tetrad, variant B). +- [x] `Q-0005` — RESOLVED (`A-0052`). +- [x] `Q-0008` — RESOLVED (`A-0054`): refresh trigger stays as shipped (inline `updateIfNeeded` on publish + manual `refresh-registry`, no new cron); reader ≤5-min stale cache kept as-is; apply **hard-fails** on a not-yet-visible kind/block (no retry/hint, no new code). +- Out of scope (unchanged): bump-validator, lockfile, yank/deprecation, template engine (tracks 2 & 3). + +**Testing** — informational; see "Testing strategy" below. The local `file:`-registry loop is the primary bed. Test-coverage state was not audited for this tracker. · VERIFY + +## Goal + +A block kind can be declared, wired into a block's model, published as part of the +block's publish flow, and resolved from the registry — entirely in the TypeScript layer. + +## In scope + +- **Kind package shape — a fourth component (tetrad).** `kind/` is a first-class block + component alongside `model/`/`workflow/`/`ui/`, declaring `BlockParams` + its own + version; scaffold generates it. The facade declares it directly in + `package.json`'s `block.components: { kind, model, workflow, ui }`, so the facade↔kind + link does not route through the model. **Decided: variant B (`Q-0004`).** Note the + model _still imports_ the kind for its **types** (`DataModelBuilder({ kind })` needs + `BlockParams`) — B decouples the _component/publish_ linkage, not the compile-time + type dependency. +- **Model wiring** — `DataModelBuilder({ kind })` and + `BlockModelV3.create({ dataModel, kind })` take the compiled kind object via an + object-style argument (consistent with the object-style `init`); init lambda typed + against `BlockParams`; compile-time conformance check (`decisions.md:19-55`). Target + signatures, not current code — a block must be on `BlockModelV3` + `DataModelBuilder` + first. These are **breaking SDK signature changes**: `DataModelBuilder` currently has + an empty constructor and `BlockModelV3.create` takes a single positional `dataModel`. +- **Reference carrier — facade component, with a `model.json` copy.** Under variant B + the authoritative kind **reference** `{name}@X.Y.Z` lives in the facade's + `block.components.kind` and flows into the block `manifest.json` (the reconciler reads + it there, without loading the model). No address is stored — the registry path is + deterministic from the reference. `model.json` still carries **its own copy** of the + reference (the version the model was compiled against), used for the version-match + check and read at runtime for export (`A-0013`). So the copy in `model.json` is no + longer what _publish_ consumes — it is the counterpart the check validates against. +- **Publish flow — kind-first, one version-match check** — the kind publishes as an + ordered first step of the block's publish flow (kind-first, reading the reference from + the facade component list); the _kind-before-block_ invariant holds **by construction** + of that ordering (publish kind → if it fails, abort → then facade). The single + publish-time check is a **version match**: the kind version `model.json` was compiled + against must equal the kind version the facade declares as a component — mismatch → + hard abort. **No stored address, no existence check, no address read-back/verify** + (this replaced the earlier read-back model); under B publish is also **decoupled from + `model.json`** as the reference source. A publish failure is a plain hard abort + (`A-0011`, `A-0012`). +- **Source-hash guard** — one `sha256` over the `kind/src/` tree, stored in the + per-version kind `manifest.json`; publishing the same version from a different source + → hard abort. Orthogonal to the address (`A-0031`). +- **Registry layout + resolution** — `kinds/{org}/{name}/` holds immutable per-version + content (`{version}/manifest.json` with sourceHash + first-upload ts, and `kind.d.ts`) + plus a per-kind **`overview.json` projection**. There is **no `implementations/` tree + and no kind-side reconciler**: the kind→implementing-blocks map (with channel) is a + **projection of block-overview reconciliation**, derived by the existing block + reconciler from block manifests + channel markers. Resolution = \*\*one projection read + - client-side semver** → newest **stable** by default, or the derived **`any`\*\* when + apply-time allow-unstable is set → fetch the block from `v2/`. No cross-registry + intersection (`A-0031`, `A-0050`, `A-0051`). +- **Version selectors** — `@X.Y.Z` (exact), `~X.Y.Z` (patch floor — behavior frozen), + `^X.Y.Z` (minor floor — behavior floats). The `.x` forms are **replaced**. Redefined + semver: major = params-break, minor = behavior, patch = added-optional-param (`A-0034`). +- **Channels** — one real marker `stable` (added on release); `--unstable` writes no + marker, so "unstable" = its absence; `any` = derived newest-regardless (`A-0051`). + +### Kind artifact (decided) + +- `BlockParams` is carried **both as a TypeScript type and as a mandatory runtime parser** + (`parseTemplateParams`). Typed callers are checked by the compiler; untyped params from a + template file are checked by the kind's parser at apply time. The two divide by where the + params came from, not by strictness. (Resolves `Q-0009` — `A-0057`.) A validation library + is the kind author's choice; zod is what the workspace kinds and the scaffold use. +- The kind descriptor is exposed via a **named export — no default export**. +- The descriptor carries the **reference** (`{org}/{name}@version`); the registry + address is **derived deterministically** from it and never stored (aligns with + `A-0013`). Source-hash is a separate integrity field in the manifest, not part of the + address. +- Published per-version registry content is `manifest.json` (sourceHash + first-upload + ts) + `kind.d.ts`. The concrete kind build mechanism (under rolldown) is open — + `Q-0005`. + +## Testing strategy + +The lifecycle assumes a registry that does not yet contain any kind. This once read as +the track's central risk ("how do we test before the first release?"). It is **resolved**: +the whole loop — build → publish → reconcile → resolve — runs locally, headless, with no +CI and no AWS, on a plain temp directory. Grounding: `BlockRegistryV2` writes through a +driver interface (`RegistryStorage`, `tools/block-tools/src/io/storage.ts`); `storageByUrl` +picks `FSStorage` for a `file:` URL and `S3Storage` for `s3:`. The reader +(`RegistryV2Reader` / `folderReaderByUrl`) supports `file:` and `http(s):`. A full local +loop is already proven by `tests/block-repo/src/simple.test.ts`. + +### Publish without CI + +CI runs each block facade's generated `prepublishOnly` = `block-tools publish -r s3://… …`. +Locally it is the **same command with a `file:` target** — no AWS credentials, `FSStorage` +is selected: + +``` +block-tools pack # → block-pack/manifest.json +block-tools publish -r file:/tmp/reg --registry-serve-url file:/tmp/reg +``` + +The new **kind-first** step (publish kind → **version-match check** → then facade) lives +in `cmd/publish.ts` and writes to the same `file:` registry (kind and block share one +registry, by construction of the single `block-tools` run). A small script/Make target +(`publish-etc-local`) runs build → pack → publish for each block in `etc/`, republishing +all kinds + facades to a throwaway registry in one command. + +### No cron needed, and no separate kind reconciler + +In production the registry is append-only plain files, and a CronJob reconciler sweeps +block manifests + channel markers into the `overview.json` projections. Two things +matter here: + +- The cron is only a **scheduler** around a reconciler function that already exists: + `BlockRegistryV2.updateIfNeeded` / `updateRegistry`, exposed as `block-tools +refresh-registry` and run inline by `block-tools publish` (`--refresh`, default on). +- The per-kind `overview.json` projection is produced by that **same block reconciler + pass** — there is **no separate kind-side reconciler** (`A-0050`). The kind→blocks map + falls out of block-overview reconciliation. + +Locally we invoke the reconciler directly — a **testing advantage**: no eventual +consistency, we control exactly when the projection rebuilds, and can assert both the +pre-refresh (no projection) and post-refresh (projection present) states. Production's +refresh-trigger cadence is a separate open question (`Q-0008`). + +### The loop, step by step + +| # | Step | Exercises | Invasiveness | +| --- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | Add a `kind/` package to an existing `etc/` block | package shape, workspace wiring | ~zero (new folder + workspace entry) | +| 2 | Build locally | `model.json` records the kind **reference** `{name}@X.Y.Z`; `BlockParams` types flow into `init` and type-check | **medium** — the breaking `DataModelBuilder({ kind })` / `create({ dataModel, kind })` signature change; only `etc/` fixtures updated, not the 47 bio blocks | +| 3 | `block-tools publish -r file:/tmp/reg` | the new kind-first publish + **version-match check** | low — a step added to `cmd/publish.ts`; storage layer untouched (already driver-based) | +| 4 | Headless tests read the `file:` registry back (vitest, modeled on `simple.test.ts`) | **core of the mechanism**: source-hash guard (same version + different `kind/src/` → hard fail), idempotent republish, resolution (single per-kind `overview.json` projection read + client-side semver → newest `stable`, or `any`), version selectors `@X.Y.Z` / `~X.Y.Z` / `^X.Y.Z` | ~zero (new test files) | +| 5 | Point a local desktop build at the registry via a small static HTTP server + one dev `config.json` line | block still installs and materializes after the kind package + kind-first publish were added | ~zero (config only, no code) | + +### Test layers + +- **L1 — Lifecycle unit tests on a `file:` registry (primary bed).** Steps 3–4. Where + lifecycle correctness lives; needs no desktop, no CI, no AWS. +- **L2 — Build-time wiring (compile, no publish).** Step 2. `etc/` blocks gain sibling + `kind/` packages via the pnpm workspace; `DataModelBuilder({ kind })` type-checks and + `block-tools build-model` records the kind reference. No registry involved. +- **L3 — Desktop end-to-end.** Step 5. **Lighter for track 1 than it looks** — full kind + _resolution_ (projection read + client-side semver, newest-block selection) is a + template engine / import concern (track 3). Track 1's L3 mainly proves a single block + still installs/materializes (it reads the kind reference from `model.json`). Do not + over-invest here for track 1. +- **L4 — Templates (export/import).** Tracks 2 and 3, later. + +### Desktop wiring detail + +The desktop assembles its registry list in `platforma-desktop-app/packages/worker/src/registry.ts` +(`buildRegistryEntries(appSettings)`) from `AppSettings` +(`packages/core/src/validation.ts`, persisted as `config.json` — `./config.json` at the +repo root in DEV). Three relevant knobs, all on the always-visible Settings page (no +dev-mode gate): + +- `devBlocksPaths` → `local-dev` registries — served straight from `dist/`, **bypasses + publish** (good for model/UI iteration, does not exercise the lifecycle). +- `additionalRegistriesV2: ["http://localhost:PORT/"]` → `remote-v2` via `HttpFolderReader` + — the **recommended L3 route**: matches the production http path, passes `z.string().url()`, + zero code changes. +- `file:///abs/path/` in the same field → `remote-v2` via `FSFolderReader` — valid + zero-infra fallback (no static server), but not the production http branch. + +## Out of scope for this track + +- bump-validator, lockfile, yank/deprecation, server-assigned patch numbers, cross-major + lineage — pushed onto author discipline + AI-harness CI checks. +- The template engine and export/import (tracks 2 and 3). + +## Open questions + +Carried from the spec (atom IDs), plus track-local ones: + +- **`Q-0004` — DECIDED: variant B (tetrad).** kind is a fourth sibling component; the + facade declares it in `block.components` and publish reads the reference there, not + through `model.json`. Implication: `kind` must be added to the middle-layer schemas + `block_components.ts` (`BlockComponents`, `BlockComponentsDescriptionRaw`), + `block_description.ts`, and `block_manifest.ts` (`BlockPackManifest`). +- **`Q-0005`** — concrete kind build mechanism under rolldown (how `manifest.json` + + `kind.d.ts` are produced; the shape of the compiled descriptor). +- **`Q-0008`** — production trigger/cadence for the overview-projection refresh (now + tied to the block reconciler's schedule). +- [TODO: migration path for legacy `BlockModel` (V1) blocks that must move to + `BlockModelV3` before they can carry a kind.] +- [TODO: backward-compat for the breaking `create()` / `DataModelBuilder` signature — + overload vs. atomic migration of all V3 blocks.] diff --git a/docs/block-kinds-templates/02-export.md b/docs/block-kinds-templates/02-export.md new file mode 100644 index 0000000000..bf3a8ff0aa --- /dev/null +++ b/docs/block-kinds-templates/02-export.md @@ -0,0 +1,569 @@ +# Track 2 — Export (Preamble) + +**Status: preamble only.** Scope and open questions, not a plan. + +Authoritative design: the `docs/text/work/projects/block-kind-and-templates/` mispec +corpus (PR #198 rework). Citations use atom IDs (`A-00NN`). + +## Goal + +Turn the active project into a `template-v1` YAML file that re-applies to an equivalent +project — the export half of the round-trip north-star. + +## In scope + +- **Serialize** the project's blocks in dependency order, reading each block's + template-descriptor output (`decisions.md:148`). +- **Template-local ids** — each block's project-local UUID is used directly as its + template-local `id`; references already stored in params carry those same ids, so + export reuses them verbatim, no remap (`decisions.md:139`). +- **Desktop command** — "Export Project as Template…" writes the file + (`decisions.md:148`). +- **`template-v1` schema** — shared with import; export must emit exactly what import + parses (see README "Schema shared across tracks"). + +## Why export can start early + +Export only needs the kind **reference** (`{name}@X.Y.Z`) recorded in `model.json` — read +back at runtime for the exported template entry (`A-0013`) — plus a block's +template-descriptor output. It does not need kind _resolution_ or the template-engine +apply lambda, so it can be built and tested against fixtures ahead of import. + +## High-Level TODO + +Dependency-ordered. Use `[~]`/`[x]` as work lands, matching the tracker convention in +`01-kind-and-lifecycle.md`. + +**Contracts first (these two gate everything below)** + +- [~] **Pin the `template-v1` schema** — prototyped in `pl-model-common` under + `lib/model/common/src/template/`: `project_template_v1.ts` (file envelope, `schema: + template-v1` marker, entry shape, `TemplateLocalRef`, zod boundary parsers, + reference validation) and `kind_selector.ts` (the `{name}@{selector}` grammar with + its three tiers). One definition, both tracks import it. Still to do: agreement with + import (track 3) and sign-off on five decisions — see "Schema Prototype" below +- [~] **Define the template-descriptor contract** — prototyped as + `.templateParams((data) => params)` on `BlockModelV3` (`A-0041`, renamed — see below), + plus facade callback #7 (`__pl_templateParams_derive`). A block exposes only its + params, typed as its kind's `Params`; references go out as ordinary `PlRef`s and the + SDK rewrites them. See "Template-Descriptor Contract" below for the audit and what it + leaves open + +**Serialization** + +- [~] **Read the kind reference back at runtime** — audited end to end and pinned by + `sdk/model/src/kind_reference.test.ts`; **no production code was needed**, the read + already works. See "Kind Reference Read-Back" below for the audit, why no helper + landed, and the one open decision it surfaced (kind-less blocks) +- [~] **Dependency-order walk** — `walkProjectForTemplateExport` in + `lib/node/pl-middle-layer/src/model/template_export.ts`, plus the ML-side invoker + `ProjectHelper.deriveTemplateParamsFromStorage` for facade callback #7. **No + topological sort was needed** — see "Dependency-Order Walk" below. Remaining: the + provider that reads real project state, which belongs with the serializer +- [~] **Template-local ids** — verbatim reuse confirmed end to end, and the one thing it + depends on is now enforced: `walkProjectForTemplateExport` rejects params that still + carry an un-rewritten project-local id. See "Template-Local Ids" below — it resolves + one of the open questions above and leaves the dangling-reference check to the + serializer +- [~] **Emit `template-v1` YAML** — `lib/node/pl-middle-layer/src/model/template_serializer.ts`: + `assembleProjectTemplateV1` → `stringifyProjectTemplateV1` → `exportProjectAsTemplateV1`. + Round-trip pinned against the import-side parser. See "Serializer" below — it + **implements a decision that is still awaiting sign-off** (kind-less blocks fail the + export). Remaining: the provider that reads real project state, which needs the + desktop surface + +**Desktop** + +- [~] **"Export Project as Template…" command** — wired end to end across both repos: + `MiddleLayer.exportProjectAsTemplate(id)` here, and the ProjectCard context-menu + item, save dialog and write in `platforma-desktop-app` on branch + `MILAB-6648_export-project-as-template` (off `origin/main`). See "Desktop Command" + below. Confirmed working against a live backend; the failure path was exercised, the + success path needs a project of kind-bearing blocks + +**Validation** + +- [~] **Fixture-based tests** — five golden `.yaml` files under + `lib/node/pl-middle-layer/test_fixtures/template-v1/`, driven by + `template_serializer_fixtures.test.ts`. Turned up a real interop bug in the emitter + — see "Golden Fixtures" below. Still open: fixtures of a *real* project (through + `ProjectMutator`), which need a backend +- [ ] **Round-trip check once import exists** — export → import → equivalent project; + the north-star acceptance criterion, deferred to track 3 landing. + +## Schema Prototype + +`lib/model/common/src/template/` — `A-0036`'s document as types plus zod boundary +parsers, with `project_template_v1.test.ts` parsing the atom's example verbatim. Exported +surface: `ProjectTemplateV1`, `ProjectTemplateV1Entry`, `ProjectTemplateV1Schema`, +`parseProjectTemplateV1`, `TemplateLocalRef`, `BlockPackReference`, +`BlockKindSelectorReference`, `collectTemplateLocalRefs`, +`validateProjectTemplateV1References`, and the params codec `TemplateForm` / +`toTemplateForm` / `fromTemplateForm`. + +**Decisions awaiting sign-off** + +1. **Home and boundary** — `pl-model-common` owns the *document* (JS value ↔ types) and + takes no `yaml` dependency, since it ships in every block-model and UI bundle. The YAML + text layer belongs to `pl-middle-layer`, which already has `yaml`. +2. **Naming** — `ProjectTemplateV1*`, not `TemplateV1*`: `PlTemplateV1` already names the + unrelated backend workflow-template resource (`lib/model/backend/src/` + `template_resources_v1.ts:90`). +3. **Reservation rule** (load-bearing) — a plain object with *exactly* the two string keys + `block` and `output` is reserved as a template-local reference anywhere inside an entry's + opaque `params`. This is what lets the engine rewrite references generically on apply + (`A-0038`) instead of every kind shipping a params codec. Reference resolvability is kept + out of the parser (`validateProjectTemplateV1References` is separate) so the parser stays + sound if the rule is rejected. Those two fields are the whole reference: `PlRef`'s + `requireEnrichments` is dropped on export, since enrichments are out of scope for + templates (operator decision, 2026-07-30). +4. **Strict selector grammar** — `X.Y.Z`, `~X.Y.Z`, `^X.Y.Z` only; `>=1.0.0`, `1.x` and + `latest` throw at parse. Deliberate divergence from `tools/block-tools`'s + `parseSelector`, which additionally tolerates a leading `@` as exact. +5. **Zod peg** — `satisfies BoundaryParser` (`z.ZodType`) + rather than the repo's `satisfies z.ZodType`: the branded string fields need a + `.transform`, whose input is a plain `string`, which the two-parameter form rejects. + +Known gap, not schema-specific: `expectTypeOf` assertions in `*.test.ts` are unenforced +repo-wide — `createVitestConfig` sets no `typecheck` and `tsconfig.base.json` excludes +test files. The template test was verified by hand against a tsconfig that includes tests. + +## Template-Descriptor Contract + +What a block exposes is one **required** builder method, the mirror image of the data model's +`init`. **Named `templateParams`, not `templateEntry`** (operator decision, 2026-07-30): the +lambda returns only params — the engine assembles the entry around them — so naming it after +the entry oversells what a block controls. `A-0041 v2.0.0` carries both the name and the +requirement; `01-kind-and-lifecycle-implementation-path.md` still says `templateEntry`. + +**Required, not optional** (operator decision, 2026-08-04 — `A-0056`): `done()` throws +without it, at the same gate that rejects a model with no `.args`. An optional projection +bought no expressiveness — an absent `params` and `params: {}` reach the same init factory +and produce the same storage (`block_migrations.ts:721` vs `:739`) — while costing fidelity +silently: a block with no projection exported an entry with no params, which applied as a +default-initialized block that looked restored. A block whose state carries nothing worth +restoring returns `{}`. + +```typescript +BlockModelV3.create({ dataModel, kind }) + .args((data) => ({ … })) + .templateParams((data) => ({ sources: data.sources })) // → the kind's Params + .done(); +``` + +- **Return type is the kind's `Params`.** `BlockModelV3` now carries a seventh type + parameter threaded from `create({ dataModel, kind })`, so a projection that drifts from + the block's own init contract fails to compile. Kind-less `create(dataModel)` leaves it + `unknown`, which makes `.templateParams` untyped there — one more reason the deprecated + overload should go. +- **References stay live in the lambda.** The block returns ordinary `PlRef`s; + `toTemplateForm` (pl-model-common) rewrites them to `{ block, output }` on the way out, + and `fromTemplateForm` reverses it on apply. That is the generic engine-side rewrite the + reservation rule buys, and it means no block ships a params codec — the answer to "does + the lambda or the engine rewrite references". +- **Omitting the method is legal**, and means "re-initialize from the kind's defaults" — + correct for a block like `pool-explorer` whose params are empty by construction. Note + `undefined` params and `{}` params are NOT interchangeable: `{}` is written out and used + as-is by `init`. +- **Facade callback #7**, `__pl_templateParams_derive`, is registered by every V3 block + whether or not it declares the method; a block without it answers `{ value: undefined }`. + Additive per the facade's compatibility rules, and `BlockStorageFacadeHandles` picks it + up automatically. + +**Does every block already produce it?** No — nothing does yet, and the ceiling is set by +kind coverage, not by this method: + +| Population | Count | Can carry `.templateParams` | +| --- | --- | --- | +| `etc/blocks/*` (SDK test blocks) | 13 | Yes — all V3, all `create({ dataModel, kind })` | +| `blocks/*` on `BlockModelV3` | 26 | Not yet — 0 have a `kind/` package | +| `blocks/*` on legacy `BlockModel` (V1) | 37 | No — needs V1 → V3 first, then a kind | +| `blocks/*` with no `model/src` | 3 | N/A (`MMseqs2`, `pseudobulk-generation`, `synthetic-repertoire-profiler`) | + +Migration path, in order: V1 → V3 (37 blocks), add a `kind/` package (63 blocks), then add +`.templateParams` per block. Only the third step is this track's; the first two are track 1 +and the blocks repo. Two of the SDK test blocks (`sum-numbers`, `table-test`) now declare +the method as worked examples. + +**Open — needs a decision** + +- **Extra fields are not rejected on the way out.** TypeScript applies no excess-property + check to an object literal returned from a contextually-typed arrow, so a lambda can return + fields the kind never declared and they land in the file. The kind's parser is no backstop + here — it runs on params coming IN, and nothing runs on what the projection hands back. The + asymmetry is now the interesting part: such a file is written happily and then rejected on + re-import by the same kind that would have caught it, so the round trip fails at the far + end rather than at the source. Options: accept it, run the kind's parser over the + projection's output too, or strip against a key list. Pinned by a test in + `sdk/model/src/template_params.test.ts`. +- **References inside a `PObjectId` are invisible to the rewrite.** `EnrichmentRef.hit` and + `EnrichmentStep.linker` are canonicalized-JSON *strings* holding a block UUID, so a + structural walk cannot see them and the UUID survives export to go stale on apply. + Whether such a reference reaches persisted params is unverified. + +## Kind Reference Read-Back + +The TODO's wording ("read the kind reference back at runtime") suggests missing plumbing. +There is none. Every hop is a verbatim passthrough, audited on disk: + +| Hop | Where | Behavior | +| --- | --- | --- | +| Bake | `sdk/model/src/block_model.ts:218,233,739` | `formatKindRef` → container-level `kind` | +| Write | `tools/block-tools/src/cmd/build-model.ts` | `JSON.stringify(config)`, no whitelist | +| model.json | `etc/blocks/*/model/dist/model.json` | `kind` at **top level**; 13/13 carry it | +| Manifest lift | `tools/block-tools/src/v2/build_dist.ts:55-70` | `modelKindReference`, fail-safe | +| Parse | `.../mutator/block-pack/block_pack.ts:43-55` | open record + cast, preserves it | +| Store | `.../mutator/block-pack/block_pack.ts:353` | `{ config: spec.config, source }` | +| Read | `.../middle_layer/util.ts:35` | hands every caller the whole `info` | + +The single loss is `extractConfigGeneric` (`lib/model/common/src/bmodel/normalization.ts`), +which drops `kind` in all four arms — deliberately: it normalizes the *render envelope*, +and `kind` is container-level. So `getBlockPackInfo(...).cfg` is kind-blind by construction +and the read point is `getBlockPackInfo(...).info.config.kind`. + +**Why no helper landed.** `BlockConfigContainer.kind` is already declared +`readonly kind?: BlockKindReference` (`container.ts:21`), so that property access already +yields `BlockKindReference | undefined` — an `extractKindRef(cfg)` wrapper would narrow +nothing, parse nothing, and resolve nothing, while becoming permanent block-author-facing +API (`pl-model-common` is re-exported wholesale by `@platforma-sdk/model`). The middle-layer +read is one expression at the serializer's own call site, and that call site does not exist +yet. What was missing was not code but *proof the path holds*, so this step landed the +test instead. + +**What the test pins** (`sdk/model/src/kind_reference.test.ts`, 6 cases, no backend): +`done()` bakes the kind at container level; the normalized config does **not** carry it +(fails loudly if anyone later routes it through the normalizer, creating two sources of +truth); a kind-less block reads back `undefined` rather than throwing; the reference widens +to the exact tier with the string unchanged; the org-scoped name survives the last-`@` +split; and the widened reference is accepted by `ProjectTemplateV1EntrySchema` as an +entry's `kind`. + +**One correction to the TODO's framing.** "No kind _resolution_ needed" is right, but a +brand *widen* is mandatory and was understated: an entry's `kind` is typed +`BlockKindSelectorReference`, so the exact reference goes through +`kindReferenceToSelectorReference`. `A-0041` fixes the tier — "the exact version the block +implements, `{name}@X.Y.Z`, read from the model's embedded kind reference" — and also +settles that export never emits a `block` override. Widening happens at the **serializer**, +not the read point, because it validates and therefore throws; every read site sits inside +a `Computable` re-evaluated on project-overview recompute, so a malformed stored reference +must not be able to break the overview of unrelated blocks. + +**Open — needs a decision** + +- **What does export do with a kind-less block?** A genuine spec contradiction, not a code + problem: an entry's `kind` is **required** (`A-0036`, `project_template_v1.ts:95` — "it + carries the params contract the entry is typed against") while a block's `kind` is + **optional** for backward compatibility (`container.ts:19-21`). For such a block there is + no legal entry to write, and no atom in the corpus decides between: (a) fail the export + and name every offending block; (b) skip the block with a warning — but downstream entries' + params may hold `TemplateLocalRef`s to it, producing a file that fails + `validateProjectTemplateV1References`; (c) emit an entry without `kind`, i.e. an invalid + file. Recommendation: **(a)**. This is not an edge case — every block published before + kinds existed, and every block still on the deprecated `create(dataModel)` overload, is + kind-less, so it is what most existing projects will hit until blocks are republished. + Gates the serializer step, not this one. + +## Dependency-Order Walk + +**The project structure is already stored in topological order, so there is no sort.** That +is not an assumption — it is enforced. `productionGraph` iterates `allBlocks(structure)` and +passes the set of blocks seen *so far* as the `allowed` set to `inferAllReferencedBlocks` +(`lib/node/pl-middle-layer/src/model/project_model_util.ts:136-143`, `args.ts:107-110`), so a +reference to a block not already above is recorded as `missingReferences` rather than as an +upstream; the upstream scan then stops at the current block (`:155-156`). `BlockGraph` +documents the same invariant on its node map ("Nodes are stored in the map in topological +order", `:40`). A block can therefore only legally reference blocks earlier in the sequence +— which is exactly `A-0036`'s ordering rule ("every block must appear after the blocks it +references") and `A-0041`'s "the engine emits entries upstream-first". Emitting in structure +order satisfies both for free. Groups are flattened in order, so cross-group order is the +structure's too. + +The walk deliberately does **not** reorder to repair a structure that violates the rule: +reordering would change which references are even legal, and the resulting file is caught by +`validateProjectTemplateV1References` anyway. Pinned by a test. + +**What landed** + +- `walkProjectForTemplateExport(structure, paramsProvider)` → + `{ entries, problems }`. Pure over plain data, 13 unit tests, no backend. It owns the four + distinctions that are easy to get wrong: structure order is the answer; `undefined` params + (block declares no `templateParams`) is legal and **not** the same as `{}`; a failed + derivation is a per-block problem rather than an abort, so every offending block is + reported in one pass; and non-object params are rejected — an entry's `params` is a mapping + (`A-0036`), but a kind carries `Params` as a type only (`A-0019`), so this walk is the only + place that can catch a lambda returning a primitive or an array. +- `ProjectHelper.deriveTemplateParamsFromStorage` — the middle layer's invoker for + `__pl_templateParams_derive`, mirroring `deriveArgsFromStorage`. This did not exist; the + callback had been registered by every V3 block since the previous step with nothing on the + ML side calling it. Unlike `derivePrerunArgsFromStorage`, it surfaces failures instead of + swallowing them: a prerun that cannot derive args just skips a block in staging, whereas an + export that silently drops one produces a template that does not describe the project. +- A block whose state cannot be read at all is a **problem**, not a silent skip — the + opposite of `productionGraph`, which skips such blocks by design. Deliberate divergence: + surviving entries may hold `TemplateLocalRef`s to the omitted block. + +**What is left, and why it is the serializer's** + +The provider that supplies real project state. Per-block storage is only reachable from +`ProjectMutator`'s batched round-trip loader (`mutator/project.ts:1870-1899`) or from inside +a Computable, and an export is a one-shot user action rather than part of the render loop — +so where that read lives depends on the desktop surface, and adding it to that +performance-tuned batching routine now would be guesswork. Note the same loader does +`info.blockConfig = extractConfig(bpInfo.config)` (`:1897`), so it is kind-blind like every +other extracted config: an exporter reading from `BlockInfoState` must take +`bpInfo.config.kind`, not `info.blockConfig`. + +## Template-Local Ids + +**There is no id namespace to translate into, so there is nothing to remap.** Both sides +of the id draw from the same source: + +| What | Where | Becomes | +| --- | --- | --- | +| `Block.id` in the structure | `project_model.ts` | the entry's `id`, verbatim | +| `PlRef.blockId` inside params | `ref.ts:9` | `TemplateLocalRef.block` via `toTemplateForm` | + +`toTemplateForm` copies `ref.blockId` straight into the reference +(`template_form.ts:49`), so an entry id and every reference naming it are the *same +string* — pinned by a test asserting exactly that identity. Nothing generates, maps, or +counts ids. + +Two framing corrections to the TODO's wording: + +- **"UUID" is the common case, not a constraint.** `addBlock` and `duplicateBlock` only + *default* the id to `randomUUID()`; both accept an explicit one + (`middle_layer/project.ts:217,288`), and the workspace test blocks use ids like + `block1`. An entry `id` is `z.string().min(1)`, so this is fine — but code and docs + should not promise UUID shape. +- **Verbatim reuse is only sound if the rewrite is complete**, and that is the real work + this step turned up. + +**What landed: a detection-parity guard.** The two walks disagree about what carries a +block id, and the export path sat on the wrong side of the gap: + +| Walk | Recognizes a `PlRef` object | Recognizes a `PlRef` serialized into a string | +| --- | --- | --- | +| `toTemplateForm` (`mapRefs`, `template_form.ts:74`) | yes | **no** | +| `inferAllReferencedBlocks` (`args.ts:40-99`) | yes | yes, peeling N `stringify` passes | + +The second is the project's own reference detector — the block dependency graph is built +from it — so it is the authority on what carries a block id. `walkProjectForTemplateExport` +now runs it over the already-template-form params and reports any id it still finds as a +per-block problem. Correct template form has none: a rewritten reference is +`{ block, output }` with no `__isRef` marker, invisible to the detector. It sits in the +walk rather than in `toTemplateForm` because the detector is middle-layer code while the +codec ships in every block-model and UI bundle. + +**This resolves the second open question under "Template-Descriptor Contract."** It was +recorded as "whether such a reference reaches persisted params is unverified". The carrier +is now verified real and precisely located: `EnrichmentRef.hit` and `EnrichmentStep.linker` +are declared as global-form `PObjectId`s, i.e. `canonicalize({ __isRef: true, blockId, +name })` (`ref.ts:139-149`) — and `args.ts`'s string-unwrapping branch exists *because* +that form occurs in real args. So this was never hypothetical, and export was silently +writing a project-local UUID into a file with no way to resolve it. It is now a named +error naming the offending block and id. Still open, and now a smaller question: +whether to *support* the case by rewriting inside the string (the apply side would have to +re-canonicalize with a matching escape depth) rather than rejecting it. Rejecting is the +right default until a block is shown to need it. + +**What is deliberately not here: the dangling-reference check.** Verbatim reuse can emit a +reference to a block that is not in the file, and this is ordinary rather than exotic: +`deleteBlock` only splices the structure (`mutator/project.ts:1420-1432`) and does not +rewrite downstream args, which is exactly why `BlockGraphNode` carries a +`missingReferences` flag. A project in that state exports entries whose params name a +block with no entry. `validateProjectTemplateV1References` already reports precisely this, +with the offending entry named in the message, and it needs the whole document — so the +serializer calls it once instead of the walk approximating it per block. + +**Noted, not fixed: duplicate ids are possible upstream.** `updateStructure` diffs +`stagingGraph`s, which key blocks by id in a `Map` (`mutator/project.ts:1282-1304`), so +adding a block with an id that already exists collapses in the diff — the new-block +initializer never fires (`:1371-1374`) and the structure ends up with two blocks of the +same id. Export would then emit two entries with the same `id`. Not export's hole to fix, +and `ProjectTemplateV1Schema` rejects duplicate ids at parse, so the exported file cannot +pass validation silently. + +## Serializer + +`lib/node/pl-middle-layer/src/model/template_serializer.ts`, three layers so each can be +tested and reused separately: + +| Function | Does | +| --- | --- | +| `assembleProjectTemplateV1(walk, kindProvider)` | walk output + kind refs → `{ document, problems }` | +| `stringifyProjectTemplateV1(document)` | document → YAML text, `lineWidth: 0` | +| `exportProjectAsTemplateV1(structure, paramsProvider, kindProvider)` | the whole thing, all-or-nothing | + +Assembly is dull by design — an entry is the block's id, its widened kind reference, and +the params the walk already collected. `block` is never emitted: the override exists to pin +an implementation against a kind version *range*, and export writes the exact version +(`A-0041`), so it has nothing left to pin. + +**The round-trip is asserted on every export, not only in tests.** `exportProjectAsTemplateV1` +runs the import-side `parseProjectTemplateV1` over the document before rendering it. That +is the cheapest possible proof of "export emits exactly what import parses", and it is a +throw rather than a problem: by then the kind grammar was checked by the widening, params +by the walk, and references by the assembler, so a failure means an assembler bug — with +the one known exception of the upstream duplicate-id hole noted under "Template-Local Ids". + +**Decision taken, still needs sign-off: kind-less blocks fail the export (option (a)).** +The recommendation under "Kind Reference Read-Back" is implemented, naming every offending +block, and nothing partial is written. Rationale for choosing rather than blocking: (a) is +the only reversible option — relaxing it later is a one-line change, whereas a shipped +export that writes invalid files or silently drops blocks cannot be un-shipped. The other +two remain available; if you want (b), the change is to demote the problem to a warning and +let `findProjectTemplateV1ReferenceProblems` catch whatever the dropped block leaves +dangling. + +**Also landed, in `pl-model-common`:** `findProjectTemplateV1ReferenceProblems` returns +`{ entryId, ref, reason, message }` per problem, and `validateProjectTemplateV1References` +is now `.map(p => p.message)` over it. Export needs per-block attribution to report which +block to fix, and the alternative was either parsing the message back apart or duplicating +the traversal. Same messages, so no caller changes. + +**What is left, and it is the same blocker as before:** the two providers. `paramsProvider` +and `kindProvider` are both plain functions over a block id, deliberately — the serializer +is pure and fixture-testable — but wiring them to a real project means reading per-block +storage and `bpInfo.config.kind`, which is reachable only from `ProjectMutator`'s batched +loader or inside a Computable. Which one is right depends on the desktop surface, so it +belongs with the desktop command. + +## Desktop Command + +Spans two repos. Nothing here is verified at runtime — see "What is not proven" below. + +**This repo — the providers, which were the standing blocker.** The serializer takes two +plain functions over a block id; supplying them for a real project needs per-block storage +and the container-level kind, and both are only reachable from inside `ProjectMutator`: + +| Piece | Where | +| --- | --- | +| `BlockInfo.kind` | carried through the batched loader, `mutator/project.ts` | +| `ProjectMutator.exportAsTemplateV1()` | assembles both providers, calls the serializer | +| `MiddleLayer.exportProjectAsTemplate(id)` | `resolveProjectId` → `withProject`, the entry point | + +**The entry point takes a project id, not an open `Project`** (operator decision, +2026-08-03: the command belongs on a project card, not in the File menu). Exporting is a +property of the stored project rather than of a session with it, and from a card the +project is usually closed — `withOpenedProject` would simply throw. Opening one to read it +would spin up trees and watchers for a one-shot read and then have to decide whether to +close them again. This matches how the other card-scoped actions work: `duplicateProject` +and `copyProjectToUser` also go through the middle layer by id. Modelled on +`setProjectMeta` — `resolveProjectId` then `withProject`. + +An earlier `Project.exportAsTemplate()` on the open-project object was removed rather than +kept alongside: the id-based method covers the open case too, and two entry points where +one is a strict subset of the other is worse than one. + +The kind needed one line in the loader: `bpInfo.config.kind` is read off the container +right where `extractConfig` already runs, so it costs no extra round-trip. This is what the +earlier note about "adding a read to that performance-tuned batching routine would be +guesswork" was worried about, and the worry turned out not to apply — nothing new is +fetched, a value already in hand is kept. `BlockInfo` now carries it alongside `config`, +which cannot: `extractConfig` normalizes the render envelope, one level below the kind. + +`exportAsTemplate` is a one-shot `withProject` read, deliberately **not** a `Computable`. +An export is a user action with an answer, not project state to watch, and deriving every +block's params in the VM is far too much to redo on each overview recompute. The mutator +touches no field, so `wasModified` is false and the transaction is never committed. + +**`platforma-desktop-app`, branch `MILAB-6648_export-project-as-template`** (branched from +`origin/main`; note `origin/main` carries a change to the same `workerApi.ts`, which does not +overlap these edits): + +| Piece | Where | +| --- | --- | +| `ExportProjectAsTemplateResult` | `packages/core/src/types/contract.ts` | +| `exportProjectAsTemplate` worker method | `packages/worker/src/workerApi.ts` | +| `ExportProjectAsTemplate(projectId)` task | `packages/main/src/tasks/` + `tasks/index.ts` | +| "Export as Template..." context-menu item | `packages/renderer/src/start/ProjectCard.vue` | + +The command sits in the project card's context menu, next to Share and above Delete — not +in the OS File menu, where an earlier draft put it. The File menu is `setApplicationMenu`, +i.e. the macOS menu bar, which this app's users never look at: the in-app menu is a +separate popup fed by `createMainMenuTemplate`. Unlike Duplicate, the export does not gate +the context menu while it runs, since it only reads. + +Three things there are worth knowing: + +- **The catalog lags.** The desktop's committed config pins `pl-middle-layer` 1.66.9; + `exportAsTemplate` is in unreleased 1.66.10. Handled with a *catalog-lag adapter*, the + convention that file already uses for two other methods — a narrow cast plus a comment + saying to delete it after the bump. Unlike those two, a missing method here cannot default + to empty, so it surfaces as "not supported by this version of the platform". Note a + developer whose working tree activates the local `file:../platforma/...tgz` overrides gets + 1.66.10 and the method in the typings directly, making the adapter redundant *there* — but + it is what keeps the committed code compiling against the catalog. +- **Render first, ask for a path second.** The opposite order shows a save dialog and only + then discovers the project cannot be exported — and with kind-less blocks failing, that is + currently the common outcome, not the rare one. +- **The document does not cross the thread boundary.** The middle layer returns both the + YAML and the parsed document; the worker rebuilds only `{ ok, yaml }` / `{ ok, problems }`, + since the text carries the same information and the caller only writes text. + +**What is not proven.** Type-checked, linted and formatted in all five touched packages +(three here, three there), and the desktop's `packages/main` suite still passes 31/31. But: + +- `ProjectMutator.exportAsTemplateV1` has **no unit test**. Every test that reaches a + mutator goes through `withTempRoot`, which needs a live backend (`PL_ADDRESS`), so those + suites do not run locally at all — they are the backend CI monorepo tests. The pure layers + below it are covered: 20 walk tests, 17 serializer tests, all backend-free. +- The Electron half cannot be exercised without a built app, so "the menu item appears, + is greyed out with no project open, and the dialog writes the file" is unverified. + +## Golden Fixtures + +`lib/node/pl-middle-layer/test_fixtures/template-v1/` — five expected `.yaml` files, +driven by `src/model/template_serializer_fixtures.test.ts`. Input side stays in TypeScript +(typed, so a fixture cannot drift from `ProjectStructure`); expected side is a file on +disk, which is the artifact a reviewer reads. + +| Fixture | Pins | +| --- | --- | +| `empty-project.yaml` | `blocks: []`, not a bare `blocks:` that parses as null | +| `minimal.yaml` | schema marker first; no `params` key at all when none were derived | +| `linear-chain.yaml` | the canonical shape — three blocks wired up, ids verbatim | +| `nested-params.yaml` | objects in arrays, refs at depth, `{}` and `[]` and `null` | +| `scalar-quoting.yaml` | strings a YAML reader would otherwise turn into something else | + +**Plain files, not snapshots** (operator decision, 2026-08-03). There is no snapshot +infrastructure in this repo to fit into — zero `toMatchSnapshot`, zero `__snapshots__` — +and a snapshot comes with `-u`, which rewrites the expectation without anyone reading it. +For a file format promised to a second implementation, changing the expectation should be +a deliberate edit visible in review. Each fixture is also parsed back with the import-side +parser, so a golden file can never be updated to something import cannot read. They live +outside `src/` because this package publishes `src/**/*`. + +**They immediately earned their keep: the emitter had an interop bug.** YAML 1.2 dropped +`yes`/`no`/`on`/`off`/`y`/`n` as booleans and dropped sexagesimal integers, so the `yaml` +package — 1.2 by default — was emitting a params value of `"yes"` as bare `yes` and +`"1:30"` as bare `1:30`. Our own parser reads those back as strings, so the round-trip +assertion was green and the field-by-field tests could not see it at all. But PyYAML's +default and Go's `yaml.v2` are **1.1**, where they are `true` and `90`. + +Fixed by emitting with `version: "1.1"` while still parsing as 1.2: quote against the +stricter ruleset, read with the looser one, since a quoted scalar means the same thing +under both. It adds no `%YAML` directive — only more quotes. Pinned by a test that asserts +each hazard is quoted, which is the only kind of test that can catch this, precisely +because a self-round-trip cannot. + +**What is left here.** Fixtures of a *real* project, i.e. driven through `ProjectMutator` +rather than through the pure serializer. These would catch a provider bug — the current +fixtures verify the format contract, not that reading a real project produces it. They +need a backend, so they belong with the monorepo CI suite. + +## Out of scope + +- Import / apply (track 3). +- Publishing or browsing templates in-app (`decisions.md:152`). + +## Open questions + +- Both original open questions are answered above. The live ones are the sign-off items + under "Schema Prototype", the extra-fields decision under "Template-Descriptor Contract", + and the kind-less-block decision under "Kind Reference Read-Back" — that last one is now + **implemented as (a)** rather than blocking, and needs confirming rather than deciding + (see "Serializer"). +- A note on code comments: source comments in this track carry no `A-00NN` citations or + paths back into these documents — they state the fact inline instead (operator decision, + 2026-08-03). Citations live here, in the tracker. diff --git a/docs/block-kinds-templates/03-import.md b/docs/block-kinds-templates/03-import.md new file mode 100644 index 0000000000..370e26ccec --- /dev/null +++ b/docs/block-kinds-templates/03-import.md @@ -0,0 +1,694 @@ +# Track 3 — Import / Apply + +**Status: preamble + high-level TODO.** Dependency ordering and grounded entry points, +not a per-file implementation path. + +Authoritative design: `docs/text/work/projects/block-kind-and-templates/decisions.md`, +section *Template engine and Desktop* (`decisions.md:125-152`). + +## Goal + +Apply a hand-authored or exported `template-v1` YAML into a **new** project: parse, +validate, resolve each entry's kind, create the project, add blocks in file order, +navigate to the result. + +## In scope + +- **Fixed native YAML lambda** — the degenerate orchestrator hardcoded in TypeScript; + no QuickJS sandbox in v1 (`decisions.md:127`). +- **Add-block / state API** — designed and used as the injected-lambda contract even + though the only v1 caller is native; the lambda reaches construction only through it + (`decisions.md:129`). Deriving this API is a hard requirement. +- **Kind resolution** — resolve each entry's `kind@selector` off the per-kind + `overview.json` projection (single read + client-side semver; depends on track 1). + Selectors: `@X.Y.Z` / `~X.Y.Z` / `^X.Y.Z`. `allow-unstable` switches from the `stable` + set to the derived `any` set for the whole apply (`A-0034`, `A-0039`). +- **Reference resolution** — engine maps each template-local `id` → fresh project-local + UUID and rewrites references to concrete *before* params reach a block's init lambda; + the block never sees an unresolved reference (`decisions.md:137,141`). +- **Desktop command** — "Create Project from Template file…", headless apply, single + `allow-unstable` checkbox (default off) (`decisions.md:147`). +- **Validation failures** — surfaced per stage, each identifying the failing entry and + cause; taxonomy/presentation left open (`decisions.md:150`). + +## Depends on + +- Track 1 kind resolution (per-kind `overview.json` projection, `~`/`^` selectors, + derived `any` channel) — **landed** in `2c2c15b3d`, see "Where the Adapter Actually + Stands". +- The `template-v1` schema shared with export (track 2). + +## Out of scope + +- QuickJS sandbox host and template-delivered custom lambdas (`decisions.md:127-131`). +- Applying into an **existing** project (`decisions.md:147`). +- Guided wizard and settings modal (`decisions.md:145`). + +## High-Level TODO + +Dependency-ordered. Use `[~]`/`[x]` as work lands, matching the tracker convention in +`01-kind-and-lifecycle.md` and `02-export.md`. Every path:line below was read, not +inferred. + +**Contracts first (these two gate everything else)** + +- [~] **Params → initial storage (facade callback #8)** — landed as + `__pl_storage_initialFromParams`, a **new** callback rather than a widened + `StorageInitial`, wired end to end: `DataModel.getDataFromParams` → + `createInitialStorageFromParams` → registration in `BlockModelV3.done()` → + `ProjectHelper.getInitialStorageFromParamsInVM`. See "The Missing Half" below for what + was missing and why the shape came out this way. `Q-0009` — validating those params + against the kind — is now answered too; see "Params Against Their Kind" +- [~] **Add-block / state API** — landed as `TemplateApplyApi` in + `lib/node/pl-middle-layer/src/model/template_apply.ts`, together with the fixed + orchestrator `applyProjectTemplateV1` that drives construction through it and nothing + else. See "The Construction Contract" below: it came out at **one method**, and the + reasons for that are the answer to the first open question. Remaining: the + implementation backed by a real project, which belongs with the engine + +**Kind resolution — consumed from track 1** + +- [~] **Resolve `kind@selector` → block pack spec** — the import side landed as + `resolveTemplateEntries` in `lib/node/pl-middle-layer/src/model/template_resolve.ts`, + against the `BlockPackProvider` port. Import owns the per-apply `allowUnstable` flag + and turning the three failure reasons into messages, both done and tested against a + fake provider. **Correction to an earlier entry here, which claimed nothing from + track 1 had landed:** `2c2c15b3d` (2026-07-24, "prototype the block-kind subsystem") + landed §5 and §6 — `RegistryV2Reader.getKindOverview` / `resolveKind`, the pure + `resolveKind` + `KindResolutionError` in + `tools/block-tools/src/v2/registry/kind_resolver.ts`, and the + `BlockPackRegistry.resolveKind` / `getOverview` facade + (`src/block_registry/registry.ts:292-323`). Remaining: the adapter, which is now + unblocked — see "Where the Adapter Actually Stands" +- [~] **Honor the entry's `block` override** — same module, converging on the same + `BlockPackSpec` as the kind path (`decisions.md:118`). Includes the npm-name → + `{organization, name}` split the schema left to import (`project_template_v1.ts:52-56`), + as `parseBlockPackName` + +**Engine — parse, validate, construct** + +- [x] **YAML text → document** — `parseProjectTemplateV1Yaml` in + `lib/node/pl-middle-layer/src/model/template_parser.ts`, mirroring + `template_serializer.ts` on the other side. Most of the work turned out to be + diagnostics rather than parsing — see "Reading a File Someone Wrote By Hand" +- [~] **Validate before the project exists** — `validateTemplateV1ForApply` in + `lib/node/pl-middle-layer/src/model/template_validate.ts`: reference consistency plus + the foreign-id guard, grouped by entry in file order. Entry shape, both grammars and + id uniqueness are already the parser's (`project_template_v1.ts:188-200`). Params + against their kind is `Q-0009`, now **resolved and implemented** — see "Params + Against Their Kind". The single report every stage feeds is `TemplateApplyReport`, + assembled by the driver +- [x] **Reject params carrying a foreign block id** — same module (operator decision, + 2026-08-03), using `inferAllReferencedBlocks` (`model/args.ts`), the detector export's + guard uses. **Correction to this plan:** it runs on the file-form params *before* any + rewrite, not after. In file form a legitimate reference is `{ block, output }`, which + the detector does not recognize at all, so everything it finds is foreign by + construction — nothing to subtract, and the check lands before the project exists, + where the plan says validation belongs. See "Stale Ids in Strings" +- [x] **Id map + reference rewrite** — `createTemplateIdMap` in + `lib/node/pl-middle-layer/src/model/template_ids.ts`: assign a fresh UUID per entry, + then `fromTemplateForm(params, resolve)` (`template_form.ts:65`, already implemented + and tested) before params reach the block. Single forward pass, as planned — file + order is instantiation order and forward references are already rejected, so every + upstream id is mapped by the time it is needed. Assignment and publication came out + as two steps rather than one; see "Two Steps, Not One" +- [x] **Construction loop** — `createTemplateApplyApi` in + `lib/node/pl-middle-layer/src/mutator/template_construct.ts` is the in-transaction + half, `MiddleLayer.applyTemplateToProject(id, document, provider, options)` the + driver. Both decisions the plan left open are resolved, and neither the way it + guessed: **one** transaction rather than N, because the construction contract already + committed to a synchronous API and `Project.addBlock` is async end to end; and + `NewBlockSpec.fromModel` gained an optional `initialStorage` rather than a third arm, + because seeding from params produces exactly the storage the block would have written + itself. See "Four Stages, and Where the Project Appears" +- [x] **Failure policy — keep the partial project and report** (operator decision, + 2026-08-03). Wired: the three stages before construction create nothing, so a bad + file leaves the project untouched, and a failure inside construction commits the + blocks that landed and reports the entry that stopped it. **Correction to this plan:** + apply does *not* create the project — the caller does, which is what makes the first + three stages free of cleanup. Deleting the project was never on the table for the + same reason it was rejected here: the blocks that landed are valid and the report is + the only record of how far it got + +**Desktop** + +- [x] **"Create Project from Template file…"** — `CreateProjectFromTemplate` in + `platforma-desktop-app/packages/main/src/tasks/`, over a `createProjectFromTemplate` + worker method. Asks for the file first (the reverse of export, which must render + before it can offer a path), parses before creating anything, then navigates to the + result. The root block pack the existing `CreateProject` auto-adds + (`tasks/CreateProject.ts:41-52`) is deliberately absent — the template supplies every + block. `allow-unstable` is a task option, default off, with no control yet: the + checkbox belongs with the dialog that does not exist. See "The Desktop Command" +- [x] **Entry point placement** — a ghost button beside "Create New Project" on the + projects list, where the create flow already lives; import creates a project, so it + has no card to hang off. UX deferred as export's was: a button and a native alert, + no dedicated dialog + +**Validation** + +- [ ] **Apply the golden fixtures** — the five files under + `lib/node/pl-middle-layer/test_fixtures/template-v1/` are ready-made inputs, and + `scalar-quoting.yaml` covers the scalar hazards a hand-authored file brings. Needs a + stub kind resolver plus a stub block-pack preparer to run without a registry +- [ ] **Round-trip** — export → import → equivalent project. The north-star acceptance + criterion and the last open checkbox in `02-export.md`. Defining "equivalent" is + part of the work: structure order, per-block derived args, not resource ids + +## The Missing Half — Params → Initial Storage + +Export's callback #7 turns a block's stored data into params. Apply needs the inverse, and +**it does not exist at runtime today** — verified end to end: + +- The type channel is already there: `DataCreateFn = (args: { params?: Params }) + => T` (`sdk/model/src/block_migrations.ts:13`), and a kind's `Params` "flows into + `.init()`" through `DataModelBuilder` (`:542-555`). +- Nothing ever fills it. `DataModel.initialData()` and `getDefaultData()` both call + `initialDataFn({})` (`:667`, `:675`), so `params` is always `undefined`. +- The facade callback takes no arguments — `StorageInitial: () => StringifiedJson` + (`sdk/model/src/block_storage_facade.ts:192`) — and neither does its middle-layer + invoker, `ProjectHelper.getInitialStorageInVM(blockConfig)` + (`model/project_helper.ts:250`). Its three callers are new-block creation + (`mutator/project.ts:1221`, the `fromModel` arm of `initializeNewBlock` — the exact seam + apply needs), `resetToInitialStorage` (`:857`) and `migrateBlockPack` (`:1541`). + +### What Landed + +A params-carrying variant at every one of those layers, mirroring what export added for #7: + +| Layer | Added | +|-------|-------| +| `sdk/model/src/block_migrations.ts` | `DataModel.getDataFromParams(params)` beside `getDefaultData()` | +| `sdk/model/src/block_storage_callbacks.ts` | `createInitialStorageFromParams`, sharing `assembleStorage` with `createInitialStorage` | +| `sdk/model/src/block_storage_facade.ts` | `StorageInitialFromParams: "__pl_storage_initialFromParams"` and its signature | +| `sdk/model/src/block_model.ts` | registration in `done()` | +| `lib/node/pl-middle-layer/src/model/project_helper.ts` | `getInitialStorageFromParamsInVM(blockConfig, params)` | + +Pinned by `sdk/model/src/template_init.test.ts` (11) and +`lib/node/pl-middle-layer/src/model/project_helper_params_init.test.ts` (7). The middle-layer +tests drive a hand-written model bundle, which is what lets every failure branch be +exercised on purpose — and needs neither a built block nor a backend. + +**A new callback, not a widened `StorageInitial`** — the one decision worth recording. A +widened callback is the smaller surface, but a block bundled with an older SDK would still +accept the call and ignore the extra argument, producing a default-initialized block that +looks like a successful apply. A separate callback is simply absent from such a block, so the +middle layer sees it missing and says so. That asymmetry between *cannot* and *silently did +not* is the whole argument, and it matches the facade's own rule that new callbacks are the +compatible way to extend it (`block_storage_facade.ts:25-32`). + +Consequences worth knowing: + +- **There is no params-less path any more** (2026-08-04 — `A-0056`). An entry that omits + `params` is read as `{}` and goes through the params callback like any other, rather than + being routed to `StorageInitial`. The block produced is identical either way — both reach + the same init factory — but only this way is the entry checked against its kind, so an + omitted key can no longer apply params the contract would have rejected. `StorageInitial` + remains the UI-creation path, where there genuinely are none. A block predating the storage + facade is still refused outright, for a reason that has nothing to do with params; see + "Four Stages, and Where the Project Appears". +- **Params cross as text, references already resolved.** `undefined` is normalized to `{}` at + the boundary, because `JSON.stringify(undefined)` is not a string and the callback would be + handed nothing. +- **A block's init factory decides what is valid.** A factory that throws is reported as a + per-entry problem, not propagated — the same collect-everything shape export uses. +- **`Q-0009` was answered at this seam.** It is the only place a hand-authored file's params + meet the kind that types them, and that is where the check landed — see "Params Against + Their Kind". + +## The Construction Contract + +`TemplateApplyApi` is one method — `addBlock({ id, params? }) → { ok, blockId } | { ok, error }` +— and the fixed orchestrator that drives it is a dozen lines. That is the design, not an +unfinished draft: everything an orchestrator does not decide was pushed to the +implementation, because every one of those decisions is either unsafe or duplicated work if +an orchestrator makes it. + +What is deliberately **not** in the request, and why: + +| Absent | Why | +|--------|-----| +| block pack / kind / version | The entry is named by its template-local `id` and the implementation looks up what it already resolved for it, so no orchestrator can substitute an implementation the document was never validated against | +| the project-local id | Assigned by the implementation, which keeps the id map in the one place that needs it to rewrite references | +| a label | A template names no block instances, so the label is the implementation's to choose — the block package's own title once the provider returns it, the entry's id until then | +| resolved references | Params cross **in file form**, references still naming entries; rewriting them is the implementation's job, since only it knows the assigned ids. An orchestrator that rewrote them would need the id map, and every orchestrator would own a copy of the same logic | + +Two properties follow from this being the contract a sandboxed orchestrator will receive, +and both constrain the eventual implementation: + +- **Plain data only.** Arguments and results are JSON values — hence failures as strings in + a result rather than throws. +- **Synchronous.** Everything slow must already be in hand before the orchestrator runs: + kinds resolved, block packs fetched, project created. What remains is in-memory work + inside a single transaction, so no async bridge is needed for the sandbox and an apply + cannot be interrupted mid-way by a network call. This also settled the transaction question + in the construction-loop item before it was asked: prepare all entries up front, add them + in one mutator pass, rather than one transaction per block as `Project.addBlock` does. + +**Stop at the first failure** — the refinement of the keep-and-report policy. Entries after +the failure may reference it, so continuing would place blocks whose upstream is missing: +a project wired to nothing in the middle is worse than one short a tail. What already +landed is kept and returned, paired file-id to assigned-id, so the caller can say how far +it got. + +Pinned by `template_apply.test.ts` (9), driven against a recording fake. That the tests need +no project, backend or registry is itself the check that the contract is narrow enough to +hand to a sandbox. + +## Reading a File Someone Wrote By Hand + +`parseProjectTemplateV1Yaml(text)` is two library calls — `YAML.parse` then the shared +`parseProjectTemplateV1` — wrapped in the diagnostics that make the difference between a +file someone can fix and one they can only re-generate. Export's reader was always our own +output; this one's input is a person's. + +Failures return one message rather than per-entry problems: until the document parses there +are no entries to attach anything to. The message is multi-line when the file has several +fixable issues, because fixing it should take one pass. + +Cases given their own wording, each replacing something unhelpful: + +| Input | Instead of | Says | +|-------|-----------|------| +| empty / blank / comments only | `expected object, received null` | "The file is empty." | +| top-level scalar or list | `expected object, received array` | what a template's top level looks like | +| `schema: template-v2`, or no `schema` | `Invalid literal value, expected "template-v1"` | that this is not a template-v1 file, and what it claims to be | +| YAML syntax error, repeated key, tab indent | the library's code frame | the same message trimmed to `at line L, column C` | +| schema issues | `blocks.0.kind` | `blocks[0].kind`, counted and listed together | + +Two behaviours worth knowing, both pinned by tests: + +- **Read as YAML 1.2**, while export quotes as if for 1.1. Deliberate on both ends: quoting + against the stricter ruleset makes a file we write mean one thing to every reader, and + reading with the looser one keeps a hand-written bare `yes` or `1:30` the string it looks + like instead of `true` or `90`. +- **Repeated keys are an error**, not last-wins. A copied entry with a field left unchanged + would otherwise apply, wrongly and silently. + +Pinned by `template_parser.test.ts` (18), which also parses every golden export fixture and +round-trips document → text → document, so the two text layers are checked against each +other and not only against files. + +## Params Against Their Kind — `Q-0009` Resolved + +**A kind ships a runtime check for its params** (operator decision, 2026-08-03, chosen to +spend build-time effort instead of debugging time later; made **mandatory** 2026-08-04 — +`A-0057`). `defineBlockKind` requires `parseTemplateParams: (value: unknown) => BlockParams`; +the SDK applies it wherever params arrive untyped, and the middle layer asks for it alone as +a pre-flight. + +This **reverses** track 1's "`BlockParams` is a pure TS type" decision rather than narrowing +it. A kind is no longer a types-only artifact: every kind ships executable code, depends on +whatever validates its params, and enters the model bundle. The validator is the author's +choice — the field is a plain function and a hand-written check satisfies it — with zod as +the default the workspace kinds and the scaffold use. + +There is no unchecked pass left, so the `checked` flag that used to distinguish one is gone +from `TemplateParamsValidationResult`, the facade callback, and `validateTemplateParamsInVM`. +The only remaining unchecked path is a block whose model predates the callback, and such a +block is refused outright at placement. + +### Why it was worth doing + +Measured on `enter-numbers` (kind params `{ numbers?: number[] }`) before the check existed +— params straight from a file, through init, to derived args: + +| YAML | Was | Now | +|------|-----|-----| +| `numbers: [3,1,2]` | `args={numbers:[1,2,3]}` | accepted | +| `number: [3,1,2]` (typo) | block created empty, later "Numbers are required!" | `Unrecognized key(s) in object: 'number'` | +| `numbers: ["3","1","2"]` | **silently** `args={numbers:["1","2","3"]}` — numeric sort became lexicographic | `numbers[0]: Expected number, received string; …` | +| `numbers: "1,2,3"` | `args() threw: not a function` | `numbers: Expected array, received string` | +| `numbers: [1], colour: red` | extra key silently dropped | `Unrecognized key(s) in object: 'colour'` | +| `numbers: null` | block created empty, later "Numbers are required!" | `numbers: Expected array, received null` | + +The third row is the one that mattered: no error anywhere, and a wrong scientific result. + +### Shape of it + +- **The kind owns the check, not the block.** Many block versions implement one kind; a + per-block check could drift between them and from the type. +- **`.strict()` is where most of the value is.** Two of the six rows above are a + misspelled or stray key — invisible to any type-shaped check that only looks at what it + knows about. +- **The parser returns the params to use**, so it can strip and coerce; its output is what + reaches the block's `init`. +- **TypeScript keeps schema and type in step**: the parser must return `BlockParams`, so a + schema missing a declared field does not compile. A schema *looser* than the type is not + caught — the honest limit. +- **Two call sites, one function.** Facade callback #9 `__pl_templateParams_validate` is + the pre-flight (`ProjectHelper.validateTemplateParamsInVM`) — nothing is created, so a bad + file is reported entry by entry with no project to half-build. Callback #8 re-checks + anyway, so the factory can never be handed params the kind refused, whichever path got + there. +- **`checked: false` is not a failure.** It reports that the kind declares no check. A + block whose model predates the callback is treated the same way: the pre-flight creates + nothing, so proceeding costs nothing, and the entry still fails clearly when applied. +- **Rejections are rendered, not dumped.** A zod error's own `message` is its whole issue + array as JSON; the SDK duck-types `{ issues: [{ path, message }] }` and renders + `numbers[0]: Expected number, received string`, matching how the file is written. No + schema library is prescribed or depended on. + +### Cost, measured — and the build change it forced + +Declaring the schema first grew `enter-numbers`' model bundle **382 kB → 501 kB**: zod was +bundled *inside* the kind's own `dist/kind.js` (that build inlines everything) and so arrived +as a second copy alongside the model's own zod. Not a static cost — +`executeSingleLambda` evaluates the whole model bundle on **every** callback invocation, so a +duplicated dependency is paid per call. + +**Fixed by building a kind twice, the way a model already is** (operator direction): + +| Artifact | Dependencies | Who consumes it | +|----------|--------------|-----------------| +| `dist/index.js` / `index.cjs` | external | blocks importing the kind | +| `dist/kind.js` | inlined | the registry, and `build-kind-manifest`'s hash | + +`createRolldownBlockKindConfig` now prepends the standard node config, and the structurer's +`kind-package-json` rule points `main`/`module`/`types`/`exports` at the externalized pair +(all three of `import`/`require`/`default` spelled out, since `build-model` reaches a kind +through `require`). The self-contained bundle stays on disk for the registry and is no longer +an entry point. + +Result: **382 kB → 401 kB**, one copy of zod in the bundle (verified), i.e. ~19 kB for the +schema itself instead of ~119 kB for the schema plus a duplicate library. A kind that +declares no check still pays nothing. + +## Validation: Two Checks, One Report + +`validateTemplateV1ForApply(document)` holds everything knowable from the document alone — +no registry, no project. That placement is the substance of the check: the same findings +made one stage later would have to be reported against a half-built project. + +- **References name an earlier entry.** Detection is shared with export + (`findProjectTemplateV1ReferenceProblems`); the wording is not. Export tells a developer + their project cannot be written out; this tells a reader which edit fixes their file — + "move 'b' above this entry", not "blocks order is the instantiation order". The shared + finding is structured (`reason` is a discriminant) precisely so each direction can word + it for its own reader. +- **No params carry a block id from another project.** The mirror of export's guard, and + the reason it is cheap: in file form a legitimate reference is `{ block, output }`, a + shape `inferAllReferencedBlocks` does not recognize, so anything it finds is foreign with + nothing to subtract. Caught in an object, in a canonicalized string, and through repeated + `JSON.stringify` nesting — the enrichment case that started this. + +Problems are grouped by entry in file order, so the report reads alongside the file and an +entry's problems appear together. Everything is collected: three mistakes, one pass. + +Params against their kind are checked separately, and one stage later: that check needs each +entry's block config, which only exists once resolution has fetched it. See "Params Against +Their Kind". + +Pinned by `template_validate.test.ts` (14). Two of them exist to keep the guard from +over-reaching: a `{ block, output }` reference must not be mistaken for a foreign id, and a +uuid that is merely *data* in params — a sample id, a note — must be left alone. + +## Resolution, and What It Left Open + +`resolveTemplateEntries(document, provider, { allowUnstable })` is the first stage of an +apply and the only one that touches the network. It runs before the project exists, which +is what makes "no block for this entry" a message about a file rather than a half-built +project — and, since it hoists all the slow work, it is also what lets the construction API +be synchronous. + +Both of an entry's routes to an implementation go through `BlockPackProvider` and come back +as the same `BlockPackSpec`, so nothing downstream cares which route an entry took: + +| Route | Port method | When | +|-------|-------------|------| +| kind selector | `byKind(kind, { allowUnstable })` | the normal case | +| pinned version | `byExactVersion(id)` | the entry carries a `block` override | + +Import owns the messages, and each of the resolver's three failure reasons has a different +way out — which is the reason they stay distinct rather than collapsing into "not found": +the selector matches no published kind version (the file or the registry is wrong), the kind +version exists but nothing implements it (nothing can be installed yet), or implementations +exist but none is stable (**import again with unstable allowed** — the only one the reader +can clear without editing the file, and the reason the checkbox exists). Every entry is +attempted and every failure collected, so an unapplicable file takes one pass to fix. +`resolved` shorter than the document is the signal not to apply it. + +`allowUnstable` is per apply, never per entry: a file that resolved some entries to stable +blocks and others to pre-releases would be unreproducible in a way the file itself does not +record. + +**Open — which registry.** A template names no registry, and both routes need one. The port +keeps that on the far side deliberately (it is a property of the environment, not the file), +but the adapter has to answer it: the primary registry only, or every configured one in +order, with a policy for a name that exists in two. Worth settling before the adapter, since +it also decides what "not found" means in a multi-registry setup. + +## Where the Adapter Actually Stands + +This tracker said for three entries that nothing from track 1 had landed. That was wrong, +and it made the adapter look blocked when it is not. `2c2c15b3d` (2026-07-24) landed the kind +subsystem prototype, including everything the port needs: + +| Port method | What already exists | +|-------------|---------------------| +| `byKind` | `BlockPackRegistry.resolveKind(registryId, ref, { allowUnstable })` (`block_registry/registry.ts:314`) → `RegistryV2Reader.resolveKind` → `getKindOverview` + the pure `resolveKind` in `block-tools/src/v2/registry/kind_resolver.ts` | +| `byExactVersion` | `BlockPackRegistry.getOverview(registryId, id, channel)` → `RegistryV2Reader.getSpecificOverview`, which returns `{ id, meta, spec }` | + +The reader's own `KindResolution` carries **exactly** the three reasons this port declares — +`no-matching-kind-version`, `no-implementation`, `no-stable-implementation` — because the port +was written against it. The one shape difference is real and unchanged: the reader *throws* +`KindResolutionError` where the port returns a union, so the adapter's `byKind` is a +try/catch that reads `e.reason`. + +What is still genuinely open is the registry question below, plus `registryId`: both facade +methods take one, while a resolved spec carries a `registryUrl`. The adapter picks the +registry, so it has the id in hand — but that is the same decision as "which registry", not a +separate one. + +## The Label, and Why It Is the Registry's + +A block created from a template is placed under the block package's published title, which +resolution carries as `ResolvedEntry.title`. The first implementation used the entry's own id +instead, which is wrong in a way worth recording, because the reasoning that produced it was +plausible: + +- **An exported template names its entries by the source project's block ids** + (`template_export.ts:19-22`), which the golden fixtures show as + `aaaaaaaa-0000-4000-8000-000000000001`. So "the id is what the file called this block" holds + only for hand-written files — on the export → import path it is a UUID. +- **That UUID would be visible.** `project_overview.ts:288` computes `label: title ?? + defaultLabel`, where `title` comes from the model's own `title` lambda and `defaultLabel` is + the structure's `label`. Nine of the 63 blocks in `blocks/*` declare no `title` lambda — + `graph-maker`, `table`, `differential-expression`, `blast`, `makeblastdb`, `gene-browser`, + `immuno-match`, `import-bulk-count-matrix`, `xsv-import` — and the desktop renders + `overview.title`, so for those the label *is* the name in the sidebar. +- **`Block.label` is `@deprecated` but not optional**, and there is no replacement field to + write instead: `title`/`subtitle` are render lambdas in the block's config, not project + state. So the question was never whether to write it, only what to write. +- **The middle layer has no other source, by construction.** `Project.addBlock` takes the + label as an argument precisely because of that, and both desktop callers pass + `pack.meta.title` off a registry listing they already fetched for display + (`AddBlockModal/components/DetailedCard.vue:123`, `main/src/tasks/CreateProject.ts:50`). A + prepared block pack carries the model, the workflow and the frontend; none of them names the + block. The update watcher returns specs, not meta. + +Hence `title` on the port, required rather than optional: an adapter always has one — it reads +the manifest — and every fallback a caller downstream could invent is worse than asking. + +**Cost, and a cheaper follow-up.** On the pinned route it is free: `getSpecificOverview` +already returns `meta`. On the kind route the adapter needs one extra manifest read after +`resolveKind`. Note that `prepare` *already* reads that manifest — `getComponents` +(`registry_reader.ts:250-265`) parses all of it and keeps only the component URLs in its LRU. +Widening that cache to retain `description.meta` would make the title free on both routes; it +touches block-tools' caching, so it is a follow-up rather than part of this. + +## What Import Gets For Free + +Track 2 left more than the schema behind. Already implemented and tested, consumed by +import unchanged: `parseProjectTemplateV1` (shape, grammars, id uniqueness), +`findProjectTemplateV1ReferenceProblems` (self / unknown / forward, structured), +`fromTemplateForm` (the apply half of the params codec), the selector grammar in +`kind_selector.ts` (`~`/`^`/exact, which track 1's `selectorToRange` translates to semver +ranges), and five golden `template-v1` files. + +## Open questions + +- ~~[TODO: concrete add-block API shape — add-by-kind vs add-by-exact-version, + inter-block reference resolution order (`decisions.md:133`).]~~ **Answered** — see "The + Construction Contract". Neither: an orchestrator adds by *entry*, and which + implementation that entry resolved to is not its to choose. Reference order is the single + forward pass, and the rewrite itself never crosses the API. +- [TODO: concrete type for a template-local reference — distinct unresolved type vs + reused reference shape (`decisions.md:143`).] **Answered by track 2**: a distinct type, + `TemplateLocalRef`, recognized structurally by the reserved `{ block, output }` shape. + Confirm rather than re-decide. +- [TODO: validation taxonomy and presentation — blocking dialog vs inline list, + fail-fast vs collect-all.] **Fail-fast vs collect-all is settled**, and by stage rather + than by taste: everything that creates nothing collects every problem, placement stops at + the first. One report shape carries both (`TemplateApplyReport`). Presentation is still + open, and is the desktop's. +- ~~**`Q-0009`** — apply-time validation of untyped YAML params.~~ **Resolved** (operator + decision, 2026-08-03; parser made mandatory 2026-08-04 — `A-0057`): every kind declares + `parseTemplateParams`, applied wherever params arrive untyped. See "Params Against Their + Kind". The bundle-size cost is now paid by every kind rather than by opt-in ones; the + twice-built kind keeps it to the schema itself (~4 kB per model) instead of a second copy + of the validator inside the model bundle. +- ~~References inside a `PObjectId` string are invisible to the structural rewrite — + decide whether apply rejects them.~~ **Decided: apply rejects** (operator decision, + 2026-08-03). See "Stale Ids in Strings" and the guard in the validation stage. + +## Stale Ids in Strings + +Why the guard above exists, since the case is easy to miss. An ordinary inter-block +reference is an object, so the rewrite sees it: + +```ts +{ __isRef: true, blockId: "aaaaaaaa-…-0001", name: "clonotypes" } +``` + +An enrichment reference carries the same object *inside a string*: `EnrichmentRef.hit` and +`EnrichmentStep.linker` are `PObjectId`s in global form, i.e. `canonicalize` of exactly that +object (`lib/model/common/src/ref.ts:139-151`): + +```yaml +params: + enrichment: + __isEnrichment: v1 + hit: '{"__isRef":true,"blockId":"aaaaaaaa-0000-4000-8000-000000000001","name":"clonotypes"}' +``` + +`mapTemplateRefs` reaches `hit`, finds a string, and returns it untouched. On apply the +entry's own id is remapped to a fresh UUID and every `{ block, output }` is rewritten, while +this string keeps pointing at a UUID from the project the file was written in — a block that +does not exist here. Nothing throws: the shape is valid, the schema is valid, the params +merely name nothing. That silence is the reason to reject rather than warn. + +Rewriting inside the string was rejected instead: it would require matching the escape +depth on both sides, and no block is known to put an enrichment reference in its params. + +## Two Steps, Not One + +`createTemplateIdMap` is the whole of the id map: `assign` hands an entry a project-local +UUID, `record` publishes it as a reference target, and `liveParams` rewrites one entry's +params from file form into live form. It lives with the `TemplateApplyApi` implementation +rather than with the orchestrator, because an orchestrator that knew the map would also own +the rewrite, and every orchestrator would then carry a copy of it. + +The plan said "assign a fresh UUID per entry, then rewrite" — one step. It came out as two, +and the split is the only design content in the module. An id is generated when a block is +about to be created, but only becomes resolvable once the block exists, so the window +between the two is where a per-entry pass rewrites that entry's params. Two things fall out +of it, both for free: + +- **An entry cannot reference itself.** Its own id is still unpublished while its params are + being rewritten, so a self-reference is reported instead of silently connecting a block to + its own output. +- **Params are never wired to a block that failed to be created.** Which matters + specifically here: a failed apply keeps the blocks that already landed, so there is no + unwind to hide a bad mapping. + +Both are already rejected by validation. The point is not to check twice — it is that the +map's ordering makes the failures unreachable rather than trusting an earlier stage, and if +one does arrive it comes back as a reported problem rather than a throw. That direction is +forced: by the time params are rewritten, earlier blocks are in the project, and the failure +policy is to keep them and say how far the apply got. A duplicate `assign` is the one thing +that does throw — entry ids are unique by the schema, so a second assignment for the same +entry means the document never went through the parser, and the first block would be +silently orphaned. + +Id generation is injectable, defaulting to the `randomUUID` that `Project.addBlock` would +have used itself. That is what lets `template_ids.test.ts` (15) run the forward pass — +assign, rewrite, create, record over a three-entry chain — with named ids and no project. + +## Four Stages, and Where the Project Appears + +`MiddleLayer.applyTemplateToProject(id, document, provider, options)` is the driver, and +`createTemplateApplyApi` is what it hands the orchestrator inside the transaction. The +stages, in order: + +| Stage | Creates | On failure | +|-------|---------|------------| +| check the document | nothing | every problem at once, project untouched | +| resolve every entry | nothing | every problem at once, project untouched | +| prepare every block | nothing in the project | every problem at once, project untouched | +| place the blocks | the blocks | stops at the entry, keeps what landed | + +The ordering is the whole failure policy. Three stages that create nothing means almost +every way a template can be wrong is reported with nothing to clean up, and the one stage +that does create is left with only in-memory work — which is what lets it be a single +transaction, and what made the synchronous construction contract implementable. + +**The project is the caller's.** The plan said apply creates it; it does not. Applying a +template is a property of the stored project rather than of a session with it — the same +reasoning `exportProjectAsTemplate` uses — so the entry point takes a project id, and +"Create Project from Template file…" is `createProject` followed by this. That also removes +the awkward case the plan carried: a document that fails validation would otherwise have +left an empty project behind. + +Two plan questions resolved, neither the way it framed them: + +- **N transactions or a batch?** Neither was open, in the end. The construction contract + fixed a synchronous `addBlock`, and `Project.addBlock` is async end to end — prepare, cache, + transaction, refresh — so it cannot be called from one. Everything slow is hoisted into + stage 3 and the transaction is entered once. The mutator's own `addBlock` is called N times + inside it, which is in-memory work. +- **`NewBlockSpec`: extend `fromModel` or add a third arm?** Extended, with an optional + `initialStorage`. What comes back from the block's params initializer is the same storage + that block would have written itself, so args derivation downstream is untouched — this is + `fromModel` with one input supplied, not a new mode. Passing it in rather than having the + mutator call the VM is also what keeps the rejection *outside* the mutation: params a block + declines are a reported problem, and the mutator gains no failure path it did not have. + +Two smaller things the implementation settled: + +- **A block too old for the facade is refused**, even with no params to ignore. Every entry + names a kind and a block predating the facade implements none, so creating one would honour + the entry's pinned version while contradicting the kind it claims. Kind resolution cannot + produce this; a `block` override can. +- **The pre-flight params check runs on the live shape with the file's own ids** + (`liveParamsForCheck`). Checking the file form directly would fail every entry that carries + a reference: a kind describing a param as a reference sees `{ block, output }` and rejects + it. Feeding it `PlRef`s whose `blockId` is still a template-local id asks the only question + this stage can answer — are the params the right shape — and leaves what they point at to + validation, which already owns it. + +The label is the block package's published title, carried from resolution. It went in as the +entry's own id first, which would have put UUIDs in the sidebar — see "The Label, and Why It +Is the Registry's". + +Pinned by `template_construct.test.ts` (13), which fakes the one mutator method construction +uses and keeps everything else real: a real `ProjectHelper`, a real model VM, real block code. +The driver itself is not covered — it needs a backend — which is why nothing but sequencing +lives in it. + +## The Desktop Command + +`CreateProjectFromTemplate` (main) over `createProjectFromTemplate` (worker), with a ghost +button beside "Create New Project". The flow, and the two decisions in it: + +1. Ask for the file. **First**, unlike export, which has to render the document before it + can offer a save path — here the file is the input, so there is nothing to compute + before asking. +2. Read it, derive the project's label from the file name, deduplicate against existing + labels (`Name (2)`, the shape `DuplicateProject` already uses). +3. Parse. A file that is not a template creates nothing. +4. `createProject`, then `applyTemplateToProject`. +5. Navigate to the project. + +**The label comes from the file name, not a prompt.** Export names its file after the +project's label (`My-Study.template.yaml`), so stripping the two suffixes recovers it and +the round trip keeps the name. It also keeps the command at one dialog, which is the whole +UX budget this step has. + +**Landing nothing drops the project again.** The middle layer's stages that create nothing +run *inside* `applyTemplateToProject`, so the worker has to create the project before it can +learn that, say, resolution failed — and then it holds an empty project the user never +asked for. So: zero blocks added and at least one problem → delete it and report. An empty +project is not a partial result, it is litter, and it is seconds old. One or more blocks +added → keep, navigate, and say what is missing, since a project short a few blocks looks +exactly like a complete one. + +`allow-unstable` is a task option defaulting to off, with nothing wired to it yet. The +checkbox the plan calls for belongs with the import dialog, and the dialog is the deferred +part; the resolution path already honours the flag, and it is one argument away. + +**Not exercised end to end, and cannot be yet.** `byKind` asks a registry for a kind +projection, and no published block declares a kind — `sdk/block-kind` exists only on this +branch. Until a kind is published, the only template that can apply against a real registry +is one whose entries carry a `block` override, which takes the `byExactVersion` route. That +is the check to run first once a kind ships. diff --git a/docs/block-kinds-templates/04-column-refs-migration.md b/docs/block-kinds-templates/04-column-refs-migration.md new file mode 100644 index 0000000000..38835867c2 --- /dev/null +++ b/docs/block-kinds-templates/04-column-refs-migration.md @@ -0,0 +1,192 @@ +# Amendment — References In `template-v1` + +Amends the schema shared by [`02-export.md`](./02-export.md) and +[`03-import.md`](./03-import.md). Status: **implemented** (`05e901eee`, `5d810ec7a`). +Nothing here is released — `template-v1` is not on main and no template has shipped — so this +replaced the earlier shape in place, with no `v2` and no migration path for existing files. + +## What Changed + +The template engine used to parse column identifiers in order to move block ids between +projects. That meant it carried a model of the whole reference system: five key forms, nesting +by string, canonicalization, identifiers in map keys. Every time that system grew, the engine +would have to grow with it — or silently drop whatever it did not recognize. + +It parses nothing now. A block's params travel **as is**, and the only structure anyone +downstream recognizes is a wrapper: + +```ts +type TemplateRef = { $ref: T }; +``` + +The SDK puts those wrappers on inside the block's own bundle, where the reference system is +already known; the document stores what is inside them verbatim; apply redirects the block ids +inside a payload textually and hands the payload back unwrapped. + +Three layers, and each knows exactly one thing: + +| Layer | Knows | +|-------|-------| +| `wrapTemplateRefs` (`pl-model-common`, runs in the block's bundle) | which values are column identifiers | +| the document | that `{ $ref: … }` marks something redirectable | +| `remapRefPayload` / `resolveTemplateRefs` | how to replace a JSON string token | + +## The File + +Params are the block's own values, with wrappers where the identifiers were. Kind names below +are illustrative; the golden fixture this mirrors is +`lib/node/pl-middle-layer/test_fixtures/template-v1/wrapped-refs.yaml`. + +```yaml +schema: template-v1 +blocks: + - id: aaaaaaaa-0000-4000-8000-000000000001 + kind: "@platforma-open/milaboratories.import-fastq.kind@2.1.0" + params: {} + + - id: bbbbbbbb-0000-4000-8000-000000000002 + kind: "@platforma-open/milaboratories.clonotype-browser.kind@1.2.10" + params: + # A filtered column id: a canonical JSON string with the block id buried two encodings + # deep. Written exactly as the block stored it — ugly, and nobody has to read it. + anchor: + $ref: '{"__isFiltered":true,"axisFilters":[[0,"IGH"]],"source":"{\"__isRef\":true,\"blockId\":\"aaaaaaaa-0000-4000-8000-000000000001\",\"name\":\"clonotypes\"}"}' + # The same column as a `PlRef` object, marked the same way. + upstream: + $ref: + __isRef: true + blockId: aaaaaaaa-0000-4000-8000-000000000001 + name: clonotypes + # Each identifier is marked where it sits, so an array of references is an array of + # wrappers rather than one wrapper around the array. + inputs: + - $ref: + __isRef: true + blockId: aaaaaaaa-0000-4000-8000-000000000001 + name: reads + # Ordinary data, untouched: a uuid that is a sample id, not a reference. + sampleId: 3f1b8c2e-5d4a-4c9f-8b17-2a6e0d9f4c31 + species: hsa +``` + +`blockId` inside a payload names a **template entry**, which on export is the block's own +project-local id — a template has no id namespace of its own, so nothing is renamed on the way +out. + +## How A Redirect Reaches A Nested Id + +`remapRefPayload` serializes the payload, replaces the ids, and parses it back. The pattern is +`String.raw`(\\*")(id1|id2)(\\*")`` — whole JSON string tokens, with **any run** of backslashes +escaping their quotes. + +That run is the whole trick. One JSON encoding around an identifier means one backslash before +its quotes, two means three, three means seven. Because the run is unbounded, nesting depth +does not exist as a case: a filtered id over a discovered id over a leaf matches at exactly one +place, and the delimiters found are put back unchanged. + +Anchoring on the quotes is not cosmetic: without it an entry id of `a` would rewrite the `a` +inside an unrelated `"reads"`. Both properties are pinned in +`lib/model/common/src/template/template_ref.test.ts`. + +Canonical form survives, because canonical JSON sorts keys and a redirect only changes values. +The one exception is below. + +## Normative Behavior + +### The Marker + +When projecting a block's template params, the SDK shall wrap each column identifier it +recognizes in a `{ $ref: … }` wrapper. + +The SDK shall recognize a column identifier in either spelling — the key object and the +canonical string — at any depth inside the params, and under any number of `JSON.stringify` +passes. + +The SDK shall not descend into a value it recognized as a column identifier. + +The SDK shall leave a value that is already wrapped unchanged. + +### Document Parser + +The document parser shall treat an object whose only key is `$ref` as a reference. + +The document parser shall not treat any other value inside an entry's `params` as a reference. + +The document parser shall not inspect what a reference wraps. + +### Exporter + +The exporter shall write an entry's `params` exactly as the block projected them. + +### Apply + +When applying a template, the apply engine shall replace, inside each reference payload, every +template-local entry id it was given with the block id that entry received. + +The apply engine shall replace each reference with its payload once the payload has been +redirected. + +If a reference payload names an entry that has no block, then the apply engine shall leave that +name unchanged. + +### Reference Validator + +The reference validator shall report a reference whose payload names the entry holding it as +`self`. + +The reference validator shall report a reference whose payload names an entry declared later in +`blocks` as `forward`. + +The reference validator shall not report a reference whose payload names an id the document +does not define. + +## What Was Deleted + +- `TemplateLocalRef` — the `{ block, output }` notation, and the reservation of that shape + inside opaque params. +- The `columns` dictionary, its interner, and the `as` surface-form marker at each reference + site. Interning deduplicated identifiers and gave block ids a single home, but it required + the engine to take identifiers apart. +- The params codec: `TemplateForm`, `toTemplateForm`, `fromTemplateForm`. +- `remapColumnIdBlockIds` and its walk (`remapIdString`, `remapKey`, `remapDiscoveredKey`), + added earlier on this branch and left with no callers. +- Both guards over an entry's params — the export-side check for block ids outside a wrapper + and for wrapped ids naming a block the project does not contain, and the import-side + `foreignBlockIds`. All three were the last places the engine modelled references. + +Kept: `peelJsonLayers`, which the project's own reference detector +(`inferAllReferencedBlocks`) is built on, now with tests of its own in +`lib/model/common/src/drivers/pframe/spec/ids.test.ts`. + +## What The Engine Can No Longer Report + +Each of these is pinned by a test that states the boundary rather than the behaviour, so it +reads as a decision and not as a bug waiting to be fixed. + +1. **A reference a block did not wrap.** Written out as data; the applied block is wired to + nothing. Not the engine's business — which values carry block ids is the block's statement + to make, and getting it wrong is a defect in the block like any other. Since the SDK now + marks references automatically, reaching this requires a genuinely unrecognizable carrier. +2. **A reference to a block the project no longer contains.** Deleting a block does not rewrite + what pointed at it, so a live project holds these routinely. Surfaces on apply. +3. **A dangling reference** — an id naming no entry in the file. Detection asks which of the + ids the document defines appear in a payload, so an id naming none is indistinguishable from + the rest of the payload's text. + +All three need the same thing: knowing which values are identifiers. + +## Still Open + +- **Canonical key order when an identifier is a map key.** `ColumnDiscoveredKey.queriesQualifications` + is `Record`, and a redirect there changes what the sorted order should be — the + result is valid JSON that is no longer canonical, so it is a different string from the + identifier the same column would have in a fresh project. Only string equality suffers. + Pinned by a test; fixing it means either re-canonicalizing a payload after the redirect + (JSON knowledge, not reference knowledge, but it would have to guess which nested strings are + JSON) or normalizing the shape on the way out, which contradicts storing values as is. +- **Blocks built before this change.** The params projection used to run inside the block's + bundle in its old form, and that bundle is frozen in `model.json` (`code.content`, stamped + with `sdkVersion`). Such a block still emits the old shape, and the engine — modelling + nothing — writes it out. Rebuilding every block is required either way on this branch, since + `kind` became mandatory; the open question is whether `BLOCK_STORAGE_FACADE_VERSION` should be + raised so an un-rebuilt block is refused instead of producing a template that does not work. diff --git a/docs/block-kinds-templates/README.md b/docs/block-kinds-templates/README.md new file mode 100644 index 0000000000..997072723e --- /dev/null +++ b/docs/block-kinds-templates/README.md @@ -0,0 +1,64 @@ +# Block Kinds & Templates — Implementation Breakdown + +Working notes for turning the spec into shippable pieces. **This is a decomposition +document, not a spec.** The authoritative design lives in the +`docs/text/work/projects/block-kind-and-templates/` mispec corpus (workspace repo), +currently as the **PR #198 rework** ("kind publishes with the facade", branch +`feat/kind-publish-with-facade`, not yet on main). When this document and the spec +disagree, the spec wins — flag the drift here. Citations use **atom IDs** (`A-00NN`) +since rendered line numbers shift. + +Status: draft. The three implementation documents below are **preambles only** — scope +and open questions, not detailed plans. + +## What we are building (one paragraph) + +A **block kind** is a separately-versioned npm package that declares a typed +`BlockParams` contract; many block versions implement one kind version. On top of kinds +sits a **template engine**: a project can be *exported* to a YAML template, and a +hand-authored or exported template can be *imported* (applied) into a fresh project. All +of it lives in the TypeScript layer — SDK, Middle Layer, Desktop. The backend +(`core/pl`) has no part. + +## Why three tracks + +The work splits along the natural seams in the spec, ordered by dependency: + +| # | Track | Depends on | Document | +|---|-------|-----------|----------| +| 1 | **Kind + lifecycle** | — (foundational) | [`01-kind-and-lifecycle.md`](./01-kind-and-lifecycle.md) | +| 2 | **Export** (project → template YAML) | kind reference in `model.json` | [`02-export.md`](./02-export.md) | +| 3 | **Import** (template YAML → new project) | kind resolution + template engine | [`03-import.md`](./03-import.md) | + +- **Kind + lifecycle is the foundation.** Nothing above it works until a kind can be + declared, wired into a block's model, published, and resolved from a registry. +- **Export and import are inverses** (`decisions.md:139`) and share the `template-v1` + YAML schema, so they must agree on that schema even though they ship as separate + streams. Export is the simpler half (serialize what already exists); import carries + kind resolution and the template-engine lambda. +- Import depends on kind resolution being real; export only needs the kind reference + recorded in `model.json`, so it can start earlier / against stubs. + +## Testing before the first release — resolved + +Kind publication is a release-time step (kind-first inside the block's publish flow). +That once read as the central risk: how do we exercise the full lifecycle — publish, +version-match check, source-hash guard, resolution — before any kind has been released? + +**Resolved.** The whole loop runs locally, headless, with no CI and no AWS, against a +plain temp directory: `block-tools`'s registry storage is driver-based (`file:` → +`FSStorage`, `s3:` → `S3Storage`) and the reader supports `file:`/`http(s):`. See +`01-kind-and-lifecycle.md` → **Testing strategy** for the step-by-step loop, layers +L1–L4, and desktop wiring. + +## Schema shared across tracks + +`template-v1` YAML is the contract between export and import. It must be pinned down +once and referenced by both. Open: where the canonical schema definition lives, and +whether it is a shared package both tracks import. + +## Out of scope (iteration 1) + +Per `decisions.md:11`: settings modal and guided wizard UI; template-delivered custom UI +pages and lambdas; applying a template into an existing project; in-app template +browsing / publishing / drag-and-drop. diff --git a/etc/blocks/blob-url-custom-protocol/.structure b/etc/blocks/blob-url-custom-protocol/.structure index 491d734467..218abba169 100644 --- a/etc/blocks/blob-url-custom-protocol/.structure +++ b/etc/blocks/blob-url-custom-protocol/.structure @@ -1 +1 @@ -{"version":1} \ No newline at end of file +{"version":2} \ No newline at end of file diff --git a/etc/blocks/blob-url-custom-protocol/block/package.json b/etc/blocks/blob-url-custom-protocol/block/package.json index e9321db2f3..d235ea42e3 100644 --- a/etc/blocks/blob-url-custom-protocol/block/package.json +++ b/etc/blocks/blob-url-custom-protocol/block/package.json @@ -25,6 +25,7 @@ }, "dependencies": {}, "devDependencies": { + "@milaboratories/milaboratories.test-blob-url-custom-protocol.kind": "workspace:*", "@milaboratories/milaboratories.test-blob-url-custom-protocol.model": "workspace:*", "@milaboratories/milaboratories.test-blob-url-custom-protocol.ui": "workspace:*", "@milaboratories/milaboratories.test-blob-url-custom-protocol.workflow": "workspace:*", diff --git a/etc/blocks/enter-numbers-v3/model/.oxfmtrc.json b/etc/blocks/blob-url-custom-protocol/kind/.oxfmtrc.json similarity index 100% rename from etc/blocks/enter-numbers-v3/model/.oxfmtrc.json rename to etc/blocks/blob-url-custom-protocol/kind/.oxfmtrc.json diff --git a/etc/blocks/enter-numbers-v3/ui/.oxlintrc.json b/etc/blocks/blob-url-custom-protocol/kind/.oxlintrc.json similarity index 71% rename from etc/blocks/enter-numbers-v3/ui/.oxlintrc.json rename to etc/blocks/blob-url-custom-protocol/kind/.oxlintrc.json index 5cb5522788..b1a139038f 100644 --- a/etc/blocks/enter-numbers-v3/ui/.oxlintrc.json +++ b/etc/blocks/blob-url-custom-protocol/kind/.oxlintrc.json @@ -1,3 +1,3 @@ { - "extends": ["node_modules/@milaboratories/ts-builder/dist/configs/oxlint-block-ui.json"] + "extends": ["node_modules/@milaboratories/ts-builder/dist/configs/oxlint-node.json"] } diff --git a/etc/blocks/blob-url-custom-protocol/kind/package.json b/etc/blocks/blob-url-custom-protocol/kind/package.json new file mode 100644 index 0000000000..9cfee5fed0 --- /dev/null +++ b/etc/blocks/blob-url-custom-protocol/kind/package.json @@ -0,0 +1,44 @@ +{ + "name": "@milaboratories/milaboratories.test-blob-url-custom-protocol.kind", + "version": "1.0.0", + "private": true, + "description": "Block kind for the blob-url-custom-protocol block", + "files": [ + "dist/**/*" + ], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "sources": "./src/index.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "ts-builder build --target block-kind && block-tools build-kind-manifest", + "check": "ts-builder check --target block-kind", + "formatter:check": "ts-builder formatter --check", + "linter:check": "ts-builder linter --check", + "types:check": "ts-builder type-check --target block-kind", + "fmt": "ts-builder format", + "watch": "ts-builder build --target block-kind --watch" + }, + "dependencies": { + "@platforma-sdk/block-kind": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "@milaboratories/ts-builder": "workspace:*", + "@milaboratories/ts-configs": "workspace:*", + "@platforma-sdk/block-tools": "workspace:*" + }, + "peerDependencies": { + "@types/node": "*", + "typescript": "*" + } +} diff --git a/etc/blocks/blob-url-custom-protocol/kind/src/index.ts b/etc/blocks/blob-url-custom-protocol/kind/src/index.ts new file mode 100644 index 0000000000..5d09a01796 --- /dev/null +++ b/etc/blocks/blob-url-custom-protocol/kind/src/index.ts @@ -0,0 +1,29 @@ +import { defineBlockKind } from "@platforma-sdk/block-kind"; +import { z } from "zod"; +import { name, version } from "../package.json" with { type: "json" }; + +/** + * Init-params contract for the blob-url-custom-protocol block — deliberately + * empty. The block's whole `BlockData` is two `ImportFileHandle`s, and those are + * desktop-signed, machine- and session-local references produced by a real OS + * file-dialog gesture (see the upload flow). Nothing a creator or a project + * template could serialize ahead of time, so this block takes no init params and + * `init` always returns the unset defaults. + */ +export type BlockParams = Record; + +/** + * The same contract at runtime, for params that arrive from a template file rather + * than from typed code. + * + * `.strict()` is the whole point for an empty contract: it turns a file that sets any + * key at all into a rejection naming that key, instead of ignoring it and applying a + * block that looks configured and is not. + */ +const Params = z.object({}).strict(); + +export const kind = defineBlockKind({ + name, + version, + parseInitializationParams: (value) => Params.parse(value), +}); diff --git a/etc/blocks/blob-url-custom-protocol/kind/tsconfig.json b/etc/blocks/blob-url-custom-protocol/kind/tsconfig.json new file mode 100644 index 0000000000..54112078cf --- /dev/null +++ b/etc/blocks/blob-url-custom-protocol/kind/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@milaboratories/ts-configs/block/facade", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "resolveJsonModule": true + }, + "include": ["src/**/*", "package.json"], + "exclude": ["dist", "node_modules"] +} diff --git a/etc/blocks/blob-url-custom-protocol/model/package.json b/etc/blocks/blob-url-custom-protocol/model/package.json index 7c71fb507b..4e52ad12de 100644 --- a/etc/blocks/blob-url-custom-protocol/model/package.json +++ b/etc/blocks/blob-url-custom-protocol/model/package.json @@ -25,6 +25,7 @@ "watch": "ts-builder build --target block-model --watch" }, "dependencies": { + "@milaboratories/milaboratories.test-blob-url-custom-protocol.kind": "workspace:*", "@platforma-sdk/model": "workspace:*", "zod": "catalog:" }, diff --git a/etc/blocks/blob-url-custom-protocol/model/src/index.ts b/etc/blocks/blob-url-custom-protocol/model/src/index.ts index a7aa2c03b4..00767519b5 100644 --- a/etc/blocks/blob-url-custom-protocol/model/src/index.ts +++ b/etc/blocks/blob-url-custom-protocol/model/src/index.ts @@ -1,10 +1,6 @@ import type { ImportFileHandle, InferHrefType, InferOutputsType } from "@platforma-sdk/model"; -import { - BlockModel, - extractArchiveAndGetURL, - getResourceField, - MainOutputs, -} from "@platforma-sdk/model"; +import { BlockModelV3, DataModelBuilder } from "@platforma-sdk/model"; +import { kind } from "@milaboratories/milaboratories.test-blob-url-custom-protocol.kind"; import { z } from "zod"; export const ImportFileHandleSchema = z @@ -14,26 +10,51 @@ export const ImportFileHandleSchema = z ((_a) => true) as (arg: string | undefined) => arg is ImportFileHandle | undefined, ); -export const BlockArgs = z.object({ +export const BlockData = z.object({ inputTgzHandle: ImportFileHandleSchema, inputZipHandle: ImportFileHandleSchema, }); -export type BlockArgs = z.infer; +export type BlockData = z.infer; -export const platforma = BlockModel.create("Heavy") +/** What the workflow consumes — projected from {@link BlockData} by the args lambda. */ +export type BlockArgs = { + inputTgzHandle: ImportFileHandle | undefined; + inputZipHandle: ImportFileHandle | undefined; +}; - .withArgs({ - inputTgzHandle: undefined, - inputZipHandle: undefined, - }) +// This block takes no init params (its kind declares `Record`): +// both fields are desktop-signed `ImportFileHandle`s, which no template can +// pre-wire. So `init` ignores params and returns the unset defaults. +const dataModel = new DataModelBuilder({ kind }) + .from("v1") + .init(() => ({ inputTgzHandle: undefined, inputZipHandle: undefined })); + +export const platforma = BlockModelV3.create({ dataModel, kind }) + + .args((data) => ({ + inputTgzHandle: data.inputTgzHandle, + inputZipHandle: data.inputZipHandle, + })) + + // Nothing to project: the kind takes no params, because both handles are signed, + // session-local references from an OS file-dialog gesture and would not resolve in + // the project a template is applied into. + .templateParams(() => ({})) .output("handleTgz", (ctx) => ctx.outputs?.resolve("handleTgz")?.getImportProgress()) .output("handleZip", (ctx) => ctx.outputs?.resolve("handleZip")?.getImportProgress()) - .output("tgz_content", extractArchiveAndGetURL(getResourceField(MainOutputs, "siteTgz"), "tgz")) + // Both archive outputs use the accessor form. V1 drove `tgz_content` through + // the config-based `extractArchiveAndGetURL(getResourceField(MainOutputs, …))` + // helpers so the block covered both surfaces; those helpers return a + // `TypedConfig`, which only V1's `output()` accepts — `BlockModelV3.output()` + // takes render lambdas only. The config surface is therefore gone here, and + // the two outputs differ solely in the archive format they extract. + .output("tgz_content", (ctx) => ctx.outputs?.resolve("siteTgz")?.extractArchiveAndGetURL("tgz")) .output("zip_content", (ctx) => ctx.outputs?.resolve("siteZip")?.extractArchiveAndGetURL("zip")) + .sections((_ctx) => { return [{ type: "link", href: "/", label: "Main" }]; }) diff --git a/etc/blocks/blob-url-custom-protocol/ui/src/MainPage.vue b/etc/blocks/blob-url-custom-protocol/ui/src/MainPage.vue index 0111dd1961..ed90b15af1 100644 --- a/etc/blocks/blob-url-custom-protocol/ui/src/MainPage.vue +++ b/etc/blocks/blob-url-custom-protocol/ui/src/MainPage.vue @@ -7,8 +7,8 @@ const app = useApp();