Skip to content

MILAB-6648: Project template - #1767

Merged
AStaroverov merged 41 commits into
mainfrom
MILAB-6648_block-kind-prototype
Aug 18, 2026
Merged

MILAB-6648: Project template#1767
AStaroverov merged 41 commits into
mainfrom
MILAB-6648_block-kind-prototype

Conversation

@AStaroverov

@AStaroverov AStaroverov commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Greptile Summary

This PR introduces versioned block kinds and project-template import/export across the model SDK, middle layer, registry, build tooling, and example blocks.

  • Block kind — a separately versioned package defining and validating a block’s initialization-parameter contract; the PR adds @platforma-sdk/block-kind, kind references, manifests, publication, and registry resolution.
  • Initialization parameters — kind-validated values used to create initial block storage; the PR threads template parameters through the storage facade into DataCreateFn.
  • Template parameters — the serializable projection of live block data used during project-template export; the PR adds the required templateParams() model callback and reference wrapping.
  • Project template — a versioned YAML document describing blocks, dependencies, kinds, and initialization parameters; the PR adds parsing, validation, serialization, resolution, application, and export.
  • Kind selector — an exact, patch-floating, or minor-floating version constraint used to resolve a compatible kind implementation; the PR adds parsing and explicit semver-range conversion, including pre-1.0 behavior.
  • Kind registry overview — registry metadata mapping kind versions to implementing block versions; the PR adds publication, reconciliation, stale-association removal, and resolution logic.
  • Block reference wrapper — a serialized marker identifying template parameters that refer to another block or column identifier; the PR adds wrapping and textual remapping during template application.
  • BlockModelV3 data model — the versioned persisted state and migration contract for a block; the PR makes block kinds and template projections part of model construction.
  • Block structure version 2 — the canonical block repository layout extended with a dedicated kind package; the PR updates structure rules, scaffolds, package validation, and example blocks.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported stale kind association, pre-1.0 selector, and dropped initialization-parameter paths are addressed in the current code.

Important Files Changed

Filename Overview
sdk/block-kind/src/index.ts Introduces the public block-kind definition and runtime parameter-validation API.
lib/model/common/src/template/project_template_v1.ts Defines the shared versioned project-template document contract.
sdk/model/src/block_migrations.ts Extends initial-data creation with optional kind parameters and correctly forwards validated template parameters.
sdk/model/src/block_model.ts Integrates block kinds and template-parameter projection into BlockModelV3 construction.
lib/node/pl-middle-layer/src/model/template_apply.ts Implements validation and application of resolved template entries into fresh project blocks.
lib/node/pl-middle-layer/src/model/template_export.ts Exports live project blocks and their projected parameters into template-v1 documents.
tools/block-tools/src/v2/registry/registry.ts Adds kind-overview reconciliation and removes obsolete associations when published block metadata changes.
tools/block-tools/src/v2/registry/kind_resolver.ts Resolves kind selectors using explicit exact, patch-floating, and minor-floating ranges.
tools/block-tools/src/cmd/publish.ts Adds kind-first, version-checked publication before writing the block facade.
tools/block-tools/src/structure/structure-definition.ts Extends canonical block structure with the new kind package.
tools/ts-builder/src/configs/rolldown/block-kind.config.ts Adds the bundler target used to build block-kind packages.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Kind[Block kind package] -->|validates init params| Model[BlockModelV3]
  Model -->|templateParams projection| Export[Template export]
  Export --> YAML[template-v1 YAML]
  YAML --> Parse[Parse and validate]
  Parse --> Resolve[Resolve kind implementations]
  Resolve --> Init[Create initial block storage]
  Init --> Project[Fresh project]
  Kind --> Publish[Kind-first publication]
  Publish --> Registry[Registry kind overview]
  Registry --> Resolve
Loading

Reviews (3): Last reviewed commit: "MILAB-6648: check a kind's required para..." | Re-trigger Greptile

Context used (3)

@notion-workspace

Copy link
Copy Markdown

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

Comment thread tools/block-tools/src/v2/registry/registry.ts Outdated
Comment thread tools/block-tools/src/v2/registry/kind_resolver.ts Outdated
Comment thread sdk/model/src/block_migrations.ts
@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c291fe2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 29 packages
Name Type
@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
@milaboratories/milaboratories.monetization-test Patch
@milaboratories/milaboratories.pool-explorer Patch
@milaboratories/milaboratories.ui-examples Patch
@milaboratories/pf-driver Patch
@milaboratories/pf-spec Patch
@milaboratories/pf-spec-driver Patch
@milaboratories/columns-collection-driver Patch
@milaboratories/pl-client Patch
@milaboratories/pl-drivers Patch
@milaboratories/pl-mcp-server Major
@milaboratories/pl-deployments Patch
@platforma-open/milaboratories.software-ptabler.schema Patch
@platforma-sdk/ui-vue Minor
@platforma-sdk/pl-cli Patch
@platforma-sdk/test Minor
@milaboratories/pl-model-backend Patch
@milaboratories/pl-errors Patch
@milaboratories/pl-tree Patch
@milaboratories/ptabler-expression-js Patch
@milaboratories/uikit Patch
@platforma-sdk/tengo-builder Patch
@platforma-sdk/bootstrap Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.67176% with 117 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.97%. Comparing base (f2fca00) to head (c291fe2).
⚠️ Report is 33 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...e/pl-middle-layer/src/middle_layer/middle_layer.ts 0.00% 39 Missing ⚠️
...b/model/common/src/template/project_template_v1.ts 72.34% 15 Missing and 11 partials ⚠️
...b/node/pl-middle-layer/src/model/project_helper.ts 57.14% 13 Missing and 2 partials ⚠️
lib/model/common/src/drivers/pframe/spec/ids.ts 77.19% 3 Missing and 10 partials ⚠️
lib/node/pl-middle-layer/src/mutator/project.ts 26.66% 11 Missing ⚠️
...ode/pl-middle-layer/src/block_registry/registry.ts 0.00% 6 Missing ⚠️
...node/pl-middle-layer/src/model/template_resolve.ts 95.45% 1 Missing and 1 partial ⚠️
...ddle-layer/src/block_registry/template_provider.ts 95.45% 0 Missing and 1 partial ⚠️
.../node/pl-middle-layer/src/model/template_parser.ts 94.11% 0 Missing and 1 partial ⚠️
...e/pl-middle-layer/src/model/template_serializer.ts 96.42% 0 Missing and 1 partial ⚠️
... and 2 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1767      +/-   ##
==========================================
+ Coverage   52.94%   52.97%   +0.03%     
==========================================
  Files         375      412      +37     
  Lines       20026    21322    +1296     
  Branches     4437     4761     +324     
==========================================
+ Hits        10602    11296     +694     
- Misses       8117     8665     +548     
- Partials     1307     1361      +54     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@AStaroverov AStaroverov changed the title [DRAFT] MILAB-6648: block kind prototype MILAB-6648: Project templates Aug 7, 2026
@AStaroverov AStaroverov changed the title MILAB-6648: Project templates MILAB-6648: Project template Aug 7, 2026
@AStaroverov

Copy link
Copy Markdown
Collaborator Author

@greptileai

@AStaroverov
AStaroverov force-pushed the MILAB-6648_block-kind-prototype branch from 9e79ae2 to 866d82b Compare August 10, 2026 14:29
Mechanical, and a prerequisite rather than part of the feature: a kind is a
mandatory component of a V3 block, so the workspace cannot hold V2 example
blocks once the following commits land.

The `-v3` suffixed duplicates that existed for migration testing are removed
and their non-suffixed counterparts take their place, so every reference to
`enter-numbers-v3` / `sum-numbers-v3` in tests moves with them. `ml-legacy.test.ts`
and `v2.test.ts` covered the V2 path only and are dropped; the two cases from
`ml-legacy.test.ts` still worth keeping are carried into `ml-v3.test.ts`.

Each block's model also projects its params, which only compiles once the
params contract lands — see the sdk/model commit later in this branch.
`BlockKindReference` is the `{name}@{version}` string a published block advertises,
and `formatKindRef` is the one place it is built from a compiled kind. The reference
sits on the config container beside `code`, not inside a render envelope: which kind
a block implements is orthogonal to how it renders, and the container is what a
reader already has in hand.

The read side stays optional. Blocks published before kinds existed carry no
reference, and the middle layer must keep reading them.
A PColumn id carries the block id of the block that produced it, sometimes several
levels down: under wrappers, in every hop of a discovered path, and in the keys of a
qualifications map. Template export and apply both have to rewrite those ids, so the
walk lives beside the id codec rather than in either caller.

Rewriting is by value: an id that does not change comes back as the very same string
rather than a re-serialized equivalent, so a caller can tell "nothing to do" from
"rewritten to the same thing".
The schema a template file is parsed against, and the codec that converts the
references inside an entry's params between the two forms they take: a live `PlRef`
naming a block, and the two-key mapping a file uses to name another entry.

Two decisions the schema makes, both load-bearing:

  - `kind` is required on every entry, because the kind carries the params contract —
    an entry without one describes params nothing can check.
  - a kind selector is its own three-tier grammar (exact, patch-float, minor-float)
    rather than an npm range, so that below 1.0.0 the tiers stay distinguishable,
    where npm's `~` and `^` collapse into the same thing.

An entry may pin a version or pin a place, never both: a pinned place is what makes a
template resolvable without a registry, and a pinned version is what makes it
portable, so a file that claims both has said nothing definite.
A kind is a block's identity plus its init-params contract, declared in its own tiny
package so that two blocks can implement the same kind and a template can name one
without naming an implementation.

The params type is recovered from the declaration (`InferBlockParams`), and a kind
with wider params is deliberately not assignable to one with narrower params — a block
must not widen the contract it claims to implement.

`ts-builder` gets the rolldown and tsconfig pair a kind package builds with; a kind
ships as its own artifact because a block bundles its dependencies once and the kind
has to be readable without unpacking the block.
The structurer gains `kind/` as a fourth component beside model, workflow and ui, so
`block-tools` scaffolds it, validates its package.json, and refuses a block without
one. New blocks get a params parser stub that deliberately does not build: a kind whose
contract was never written should fail loudly at build time, not silently accept
anything.

A kind is built twice — once as its own publishable artifact, once bundled into the
block — because the registry must be able to read a kind's contract without unpacking
any implementation, while the block still needs it at runtime.

`checkKindVersionMatch` is the gate publishing runs before anything is written: the
kind a block claims and the kind that was built have to agree on name, org and version,
with an incidental npm-scope difference normalized away.
Publishing is kind-first: the version-match gate, then the kind, then the block. A
kind version's content folder is immutable — republishing identical content is an
idempotent no-op, republishing the same version with different content is a hard
failure, because a template that pinned that version would otherwise silently mean
something else.

Reconcile derives a per-kind overview (which kind versions exist, which blocks
implement each, in which channels) in the same pass that already reconciles packages,
rather than in a second reconciler. The overview is a derived view, so a channel change
after the fact re-derives it.

`resolveKind` maps a selector tier to an explicit semver range and picks the newest
matching kind version with a stable implementer, falling back to the any channel only
when the caller allows unstable.

The block-repo tests carry this on the same axes block publishing is already covered
on, and across two kind versions — with a single version on the registry every
selector picks the same block whether or not the ranges are right.
Three things arrive together because the type parameter that carries the kind's params
threads through all of them:

  - `create()` now takes a kind, and the authoring API can no longer produce a
    kind-less block. The kind handed to the data model and the one handed to `create()`
    are cross-checked, so there is one source rather than a precedence order.
  - `templateParams()` is required. It is the inverse of the data model's `init`:
    `init` builds data from params, this recovers the params that would rebuild the
    current data. A block whose state carries nothing worth restoring returns `{}` and
    says so, rather than exporting an entry that silently applies as a default block.
  - `parseTemplateParams` is read off the compiled kind, never declared per block.
    The contract belongs to the kind, so two blocks implementing it cannot disagree
    about what a valid params object is.

There is no unchecked path: what flows on to `init` is the parser's output, not its
input, so params the kind rejects never reach a block.

A known hole, left explicit in the tests: an extra field the kind does not declare is
not rejected, because a kind's params are a TypeScript type with no runtime schema of
their own beyond what the parser chooses to check.
Generated, and uniform by construction: one `kind/` package per example block, four
config files and a params declaration each. Nothing here is a design decision — the
contracts these kinds declare are whatever the corresponding block model already
projects in its `templateParams()`.

Worth skimming rather than reading, with one exception: the params type in each
`kind/src/index.ts` is the contract a template file is checked against, so it is the
one line per block that carries meaning.
Generated. `pnpm-workspace.yaml` picks up the `kind/` glob and the lockfile follows.
A dependency-order walk over the project structure, then a serializer that renders the
result as `template-v1` YAML. Structure order is already instantiation order, so the
walk sorts nothing.

All or nothing: no partial YAML is ever produced. The walk collects every problem and
the serializer adds its own, and they are reported together in one pass, because a
half-written template is worse than none.

The central check is that no live block id escapes into the file. A `PlRef` the codec
failed to rewrite is caught, and so is one hidden inside a string (the `EnrichmentRef`
case) or double-stringified. A column id naming a block the template describes is a
wire and is fine; one naming a block it does not describe is a fault. A block cannot
name itself, since its own id is only published once it has been written.

`mutator/project.ts` gains the kind reference on `BlockInfo` — `extractConfig`
normalizes the render envelope one level below where the kind sits, so it has to be
read off the container during the load that is already happening. The `initialStorage`
seam added here is what the apply path later fills in.
The three steps before anything is created: parse the YAML, check the document on its
own, then resolve each entry to a concrete block pack.

Parse errors are located the way the file is written, and every schema problem is
listed rather than just the first, because a hand-edited template usually has more than
one thing wrong with it.

Validation runs before creation, not during: forward references, self-references,
references to ids the file does not define, and — the case that only shows up with a
file from another machine — params still carrying a live block id from the project they
were exported from, including one hidden inside a string.

Resolution has two routes. An entry that names a place is read from that place and
consults no registry at all, which is the whole point of naming one; its declared kind
is then verified against what was actually found there. An entry that names a kind goes
to the configured registries in order, first hit winning, and when they disagree the
failure that got furthest is the one reported — a registry that has never heard of the
kind is less informative than one that has it but only unstable.

Every entry is attempted before anything is reported, so a document that half resolves
says so about both halves.
Creation, in file order, with an id map recording which block each entry became so the
next entry's references can point at blocks that already exist. A forward reference is
impossible by construction here rather than merely rejected.

Every entry goes through the params path, including one with no params, which is
applied as `{}`. There is no second, unchecked way in — that is what makes "the kind
checked these params" true of every block a template creates.

Params are turned into storage in the model VM before the transaction is touched, so a
kind rejecting them is a value the caller can report rather than a half-applied
project. Apply stops at the first entry it cannot add and reports what already landed:
partial is visible, never silent. A rejected entry does not become a reference target.

`MiddleLayer` gains both public entry points. They take a project id rather than an
open project, because exporting and applying are properties of the stored project, not
of a session with it — the export command lives on a project card where the project is
usually closed, and the import flow has just created the project and has no session yet.
Export and apply are inverses. The two halves only a live project can exercise are
deriving every block's params in the model VM off stored state, and resolving,
installing and placing blocks against a real backend; everything between them is unit
tested in the middle layer, so what is proven here is that the two ends compose.

Applying goes through the YAML text rather than the in-memory document, so what a user
would actually save is what the importer reads. Equivalence is stated up to renaming,
since applying creates new blocks with new ids.

The fidelity boundary is asserted rather than avoided: a field the kind declares
survives, a field it does not is lost and the test says so. Transport is held constant
— both blocks are installed from a folder on disk, with `resolveKind` stubbed to throw,
so a located entry reaching a registry would fail the test rather than pass quietly.
The working documents this branch was built from: the kind and lifecycle design, the
export and import designs, and the resolved-question log each carries. Kept because the
decisions behind several non-obvious choices — the selector grammar below 1.0.0, kind
built twice, params checked by the kind rather than the block — are recorded here and
nowhere else.

Separated into its own commit so the code can be reviewed without it, and so it can be
dropped or relocated without touching anything else.

@dbolotin dbolotin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So yes, the issue with the reference mapping is real (see #1781), and overall by-shape recognition seems to be not the best approach here.

Comment thread sdk/block-kind/src/descriptor.ts
Comment thread sdk/block-kind/src/index.ts Outdated
Comment thread sdk/block-kind/src/index.ts
Comment thread sdk/model/src/block_migrations.ts Outdated
Comment thread sdk/block-kind/src/index.ts Outdated
Comment thread lib/node/pl-middle-layer/src/mutator/template_construct.ts Outdated
Comment thread tools/block-tools/src/structure/rules/kind-package-json.ts Outdated
Comment thread tools/block-tools/src/structure/templates/static/kind/src/index.ts Outdated
Comment thread tools/block-tools/src/v2/registry/registry.ts Outdated
Comment on lines +220 to +228
// Seed every existing kind overview empty so kinds orphaned by
// migration/removal are rewritten (or deleted) this pass. The block scan
// above already re-enumerates every live kind ref (refs live inside block
// manifests); this LIST exists solely to reset orphans.
const kindPaths = await this.storage.listFiles(KindsPrefix);
for (const rel of kindPaths) {
if (!KindOverviewPathPattern.test(rel)) continue;
touchedKinds.set(KindsPrefix + rel, { touched: null, add: [] });
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an expensive operation that is O(N) from the number of kinds == number of blocks, let's do it only for mode == "force" only. Please check that I am not missing something, if my understanding is correct it should work okay, even for deletion, if it is not due to real deletion of the block, which anyway is processed only for the force mode.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have to read all kinds each time we do this sync, let's rethink how we approach it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. It is already force-only. The block sits inside if (mode === "force") — opened at L219, closed at L239. The incremental pass never runs this LIST.

  2. Kinds ≠ blocks. One overview file per kind package (kinds/{org}/{name}/overview.json), and many block versions implement one kind — so it is far fewer than blocks. Also, listFiles(MainPrefix) at L221, in the same branch, enumerates every version of every package: strictly more than this one.

  3. One LIST, no reads. No getKindOverviewAt per kind — for a rebuild plan the existing content is discarded, so it is not fetched at all (that GET was dropped in 29741cc).

  4. Deletion — your understanding is right, with one addition. A seeded version whose manifest is gone IS handled incrementally: its entry is marked replaced at L299, then the manifest read continues at L312, so nothing re-adds it. What the incremental pass cannot see is deletion with no seed — direct removal, migration, a kind rename — which is exactly what this pre-seed exists for.

  5. Worth fixing anyway: 20 lines separate if (mode === "force") from here, so "force-only" is invisible while reading. Happy to extract forceAllVersionsToBeUpdated() and seedKindOverviewsForRebuild() so the gating reads in one line.

The engine parsed column identifiers to move block ids between projects, which
meant it carried a model of the whole reference system: five key forms, nesting
by string, canonicalization, ids in map keys. Every time that system grows, the
engine would have to grow with it or silently drop what it did not recognize.

It no longer parses anything. A block marks the params values that carry block
ids with `toTemplateRef`, the document stores what is inside verbatim, and apply
redirects the ids textually — whole JSON string tokens, with however many
backslashes escape their quotes, which is what makes nesting depth irrelevant.
The wrapper comes off before the kind sees params, so a params contract stays
written in live terms.

Gone with it: the params codec (`toTemplateForm` / `fromTemplateForm`, `TemplateForm`)
and the column-id remapper added earlier on this branch, which had no callers left.
`peelJsonLayers` stays — the project's own reference detector is built on it — and
now has tests of its own.

Three things the engine can no longer report, each pinned by a test that states
the boundary rather than the behaviour: a reference a block forgot to wrap, a
reference to a block the project no longer contains, and a reference to an id the
file does not define. All three need knowing which values are identifiers.
`templateParams()` had to wrap every value carrying a block id in `toTemplateRef`
itself. That put a template concern into every block's own code, and made a
forgotten wrapper a silent bug: the reference would be written out as data and the
applied block wired to nothing.

The SDK does it now. `wrapTemplateRefs` walks whatever the lambda returned and
marks each column identifier it recognizes — either spelling, at any depth, at
whatever escape layer it sits. It runs inside the block's bundle, which is the last
place that knows the reference system, and past it nothing looks into a wrapper
again. So the lambda is back to returning plain `Params`, and `TemplateParamsOf`
is gone.

Wrapping precisely rather than per field also narrows what a redirect can touch: a
string that merely looks like a block id is no longer inside a payload just because
it sat next to a real reference.

`toTemplateRef` stays exported as the escape hatch for a carrier the walk cannot
recognize — a foreign document holding a reference — and the walk leaves an already
wrapped value alone.
The note was drafted against the shape this branch tried first — a document-level
`columns` dictionary with `$ref` pointers and an `as` marker per use site — and
that shape is gone. Rewritten against what shipped: the SDK marks column
identifiers inside the block's bundle, the document stores the payloads verbatim,
and apply redirects the block ids textually.

Carries the three things the engine deliberately cannot report, each with the test
that pins it, and the two questions still open — canonical key order when an
identifier is a map key, and whether the facade version should be raised so a block
built before this change is refused rather than exporting the old shape.
`TemplateApplyApi` was an interface with one implementation and one caller, and
`applyProjectTemplateV1` behind it was a for-loop with an early return. The stated
reason for the indirection was a future template-supplied orchestrator running in a
sandbox — and that does not hold up: a host function exposed to the synchronous
quickjs context cannot await, so preparation has to finish before the script starts,
so the script can only reorder and re-parametrize a set of blocks someone else already
chose. Anything worth sandboxing needs to choose the set, which needs async
preparation, which needs a declare-then-execute shape this interface is not.

So it is one function now: `applyTemplateEntries({document, placer, projectHelper,
entries})`, reading top to bottom — facade check, assign, redirect, storage, place,
record. `TemplateApplyApi`, `AddBlockRequest`, `AddBlockOutcome` and the factory are
gone; the result types stay, since the middle layer's public surface and the desktop
read them.

The tests move with it: what asserted the indirection is dropped, what asserted
observable behaviour (file order, stopping at the first failure, keeping what landed,
an empty document) now runs against real block code through a real model VM instead of
a recording fake.
`touched: Set<string> | null` named neither its contents nor its purpose. It holds
block-pack ids *with version*, and what happens to them is that their existing
`implementers` entries get replaced by the freshly scanned ones — so it is
`replacedBlockIds`, and the accumulator is a planned rewrite of one overview file,
not a "touch" of anything.

`null` meanwhile carried the mode: force mode rebuilds from the scan, so there is
nothing to filter. As an absent collection that made "rebuild everything" and
"replace nothing" look identical at the use site, and needed a `!` to read. Now a
discriminant, which also removes the branch that built an empty `KindOverview` just
to filter its empty `implementers`.
`BlockKindMeta` restated `CompiledBlockKindV1` field for field, minus `kindSchema` —
same names, same types, and the same prose about `name` being the full npm package
name, with `parseTemplateParams` pointing at the descriptor for what it must do. Two
declarations of one shape, free to drift.

It is now `Omit<CompiledBlockKindV1<P>, "kindSchema" | "__PHANTOM_BLOCK_PARAMS__">`:
the descriptor minus what a caller cannot supply — the schema version this package
stamps, and the phantom slot the factory does not copy. A field added to the descriptor
now has to be supplied by callers, which is the right default.

The phantom slot keeps doing its job: `CompiledBlockKind<{ref, k}>` still does not
assign to `CompiledBlockKind<{ref}>`.
The parser checks the params a block is initialized with. A template is one source
of such params — today the only one — but naming the slot after it says the kind's
contract belongs to the template engine, which is the opposite of where this PR has
been heading. The sibling facade step already reads `storage_initialFromParams`, so
this also stops the pair from disagreeing.

Renamed at every site (68), including the 13 workspace kind packages and the kind
scaffold in block-tools, so the concept has one name. Two docstrings that framed the
slot as template-only are reworded: the descriptor now says a template is the current
case rather than the reason, and the builder's params-erasure note no longer claims the
template engine is the only caller that can supply params.

Also fixes two stale expectations in sdk/model that still described the old
`{ block, output }` reference form — they predate the `$ref` wrapper and were missed
because that change was verified against pl-model-common and pl-middle-layer only.
`kindRef` and its parser were declared four times — in `BuilderState` and in all three
chain constructors, each with its own copy of the docstring. They are one thing: a
reference with no parser cannot check the params it names, and a parser with no
reference has nothing to check them against. So `KindWiring`, once, and the
constructors intersect it.

`RecoverState` already existed and was already used by `recoverState()`, but its two
fields were re-declared in `BuilderState` and in the recover chain's constructor as
well; those now intersect it too, and the note about `recoverFromIndex` moved to the
type instead of living in a copy.

Answering the review point that asked for a generic parameter here: there is nothing
for one to bind. None of these fields mentions `Current`, `Transfers` or `Params`, and
typing the parser as `(value: unknown) => Params` collides with the chain's
`Params = never` default — a real parser is not assignable to it. What made the
constructors hard to read was the anonymous repetition, which is what this removes.

`steps` and `transferSteps` are deliberately left per site: their optionality genuinely
differs — no steps yet at the public entry, no transfers until one is declared — and
folding that into one type would need the `{a?: T} & {a: T}` trick, which trades the
duplication for a rule the reader has to know.
Comments cited the mispec by atom id — `Q-0004`, `Q-0005`, `A-0052`, `A-0053` — and
by section, `doc §3` / `doc §5`. None of that resolves for someone reading the code,
and the citation usually stood where the fact itself belonged.

Each is replaced by what it said:

- `block_kind_ref.ts` — the open decision is named ("whether the name segment has to
  be org-qualified for global uniqueness") instead of pointed at.
- `resolve-refs.ts` — the module note now states that a kind is a fourth component
  alongside model/ui/workflow, that the facade depends on it directly, and why the
  version comes from the kind's built manifest rather than from the dependency range.
  Dropped "the tetrad", which is spec vocabulary.
- `resolve-refs.ts` — the "RECONCILIATION vs. doc §4 pseudocode" note becomes a plain
  statement of where the ref lives and why (`description.kind`, lifted to the top level
  when the manifest is written); the drifting `registry.ts:301` pointer is gone.
- `publish-block.ts` — "Q-0004 quarantined inside resolve-refs" becomes what the
  quarantine is for: every assumption about how the facade names and locates its kind
  lives in that module.
- `registry.ts` — the one-line `see doc §5` now says the reason and points at the
  method's own note, which already spells it out.
- `block_model.ts` — `see doc §3` becomes why the guard has to exist at runtime:
  nothing ties the two kind objects together at the type level.

Untouched: `A-0023` in `package-builder-lib`, which predates this branch and cites a
different spec.
Per review: no half-created projects, and `assign` records the id itself.

A rejected entry now throws `TemplateEntryRejected` instead of returning a problem.
The throw leaves the caller's transaction uncommitted, so the project keeps none of
the blocks the apply had placed — `problems` non-empty now implies `added` empty at
every stage, not just the first three. A half-applied project was never a useful
result: its tail is missing, so whatever the absent entries fed is wired to nothing,
and the user cannot tell which of the blocks present the file configured.

What that removed:

- `template_ids.ts` and its test — gone. `assign`/`record` existed to keep an entry out
  of the id map until its block landed, which mattered only because a later entry could
  survive in a partially applied project. Nothing survives now, so id creation and the
  map write are one line in the loop, which is what the review asked for. The map is a
  local `Map` and the injectable id source is a `newBlockId` dep.
- `TemplateApplyOutcome` — success is total, so the loop returns `AppliedEntry[]`.
- `TemplateParamsRewrite`'s `{ ok: false }` arm and the `if (!live.ok)` check — dead since
  references stopped being parsed: `resolveTemplateRefs` leaves an id it was not given
  alone rather than failing, so the rewrite had no failure to report.
- the `stop()` helper — one throw type replaces four early returns.

`liveParamsForCheck` moved to `template_apply.ts`, its old module having been named for
ids it no longer holds.

Note for the desktop, which consumes the published middle layer: its
`added.length === 0 && problems.length > 0` branch now covers every failure, which is
the intended path. The other branch's `problems` is unreachable — a "partially applied"
report can no longer occur. Nothing there breaks; the dead arm is that repo's cleanup.
`params` was optional all the way down, so every reader normalized it and one of
them would eventually forget. A file may still omit the key — that is terseness for
whoever writes one by hand — but the parser now reads the omission as `{}`, and the
document type says so: `params: Record<string, unknown>`, required.

The two normalizations that existed downstream are gone with it: `entry.params ?? {}`
in the apply loop and in the pre-flight check. Nothing distinguished absent from `{}`
anyway — our own exporter always wrote the key.

Also removes an impossible state on the export side. `TemplateExportEntry.params` was
`Record | undefined`, documented as "written as an entry with no `params` key" for a
block built against an older SDK — but the walk's object check rejects a non-mapping
first, so `undefined` never reached an entry. The type and both docstrings that
described that path now say what actually happens: a block that projects no mapping is
reported as that block's problem.

Left alone: `params ?? {}` at the VM boundary in `project_helper`, where the parameter
is `unknown` and the guard is about an untyped caller, and the block-side `init({ params })`
default, where a block genuinely can be created with no params at all.
`block` and `location` answer different questions — which version to install, and
where to install it from — and an entry carrying both states two things with no way to
reconcile them. The parser rejected that, but the type allowed it, so the rule was only
true at runtime.

It is now `BlockPackLocatorOverride`, two arms that each forbid the other's field by
typing it `never`, intersected into the entry. `{ block, location }` matches neither arm;
either field alone, or neither, matches one. Readers are unaffected — both arms declare
both keys, so `entry.block` and `entry.location` stay readable without narrowing.

The zod schema keeps the `superRefine` as the runtime check and narrows with a transform
at the end, because a `z.union` of the two arms would report every arm's failure: a
misspelled `kind` would come back as two unrelated complaints about `block` and
`location`.

This immediately caught something: `template_resolve.test.ts`'s entry helper took
`Partial<Pick<Entry, "kind" | "block" | "location">>`, which let a test construct an
entry with both. It takes the locator type now.
The identifier became `parseInitializationParams`, but the wire names still said
template: `__pl_templateParams_derive` and `__pl_templateParams_validate`. They are the
last place the old word survived, and they sat next to `__pl_storage_initialFromParams`,
which already reads the new way.

Now `__pl_initializationParams_derive` and `__pl_initializationParams_validate`, with the
constants (`InitializationParamsDerive`, `InitializationParamsValidate`) and the two
result types renamed with them.

Safe despite the facade's freeze rule — "NEVER change the signature of existing
callbacks, NEVER remove existing callbacks" — because both were added on this branch
(`312fb2025`) and nothing published registers them. The checked-in-looking block packs
under `tests/block-repo/work/` that carry the old string are generated and gitignored.

Also dropped "facade callback #7" from a docstring: the callback list it counted into has
no numbering, so the pointer was already unresolvable.

Left as they are, deliberately: `.templateParams()` on the block builder, which is
block-author-facing API and its own decision, and the SDK/middle-layer function names
that implement these callbacks.
Moving `block` and `location` into `BlockPackLocatorOverride` left their meaning behind
in the entry's docstring, so the type that declares them said only how the exclusion is
encoded, and a reader hovering `entry.block` got nothing.

Each field now carries what it answers and what it costs: `block` names WHICH VERSION and
stays portable, is exact-only, and is hand-written because export already records the
exact version a block implements; `location` names WHICH PLACE, is the only answer for a
block that is built but not published, and buys that by being meaningless anywhere the
place does not exist — which is why export writes it for a filesystem-installed block and
never for a registry one.

The entry's docstring keeps only what belongs to the entry: at most one override, and
either one skips kind resolution — verified against `template_resolve.ts`, where both
branches return before `entry.kind` is read at all.
The docstring claimed "everything wrong with a template document that can be known from
the document alone". It checks one thing: that a reference names an entry declared
earlier. Several things knowable from the document alone are not checked at all — some
never were, and one was deliberately removed with the reference guards — so the sentence
read as a guarantee the function does not give.

It now names the check, and lists what it cannot report and why: a reference to an id the
file does not define (detection asks which known ids appear in a payload, so an unknown
one is indistinguishable from text), a block id outside a wrapper (nothing here models a
reference beyond the wrapper), and anything needing a registry or the block's own model.

The body collapsed with it. Regrouping problems into a per-entry map and re-walking
`blocks` existed to interleave two sources of findings in file order; there is one source
now, and `findProjectTemplateV1ReferenceProblems` already walks `blocks` itself — so it is
a `map` over what that returns.
A template is a file a person edits, so the reader's output is a bug report. That means
owning the wording, which a schema library gives away: zod produced "Invalid literal
value, expected \"template-v1\"" where the sentence to say is which file was picked, and
"Unrecognized key(s) in object" where it is which key was misspelled.

`readProjectTemplateV1(value)` now walks the value by hand and returns
`{ ok: true, document } | { ok: false, issues }`, each issue carrying a path and a message.
`parseProjectTemplateV1` stays as the throwing form for export, which asserts on every run
that what it wrote reads back.

The schema was carrying the trivial half anyway. The reference grammars are functions —
`parseKindSelectorReference`, `parseBlockPackReference`, `parseBlockPackLocation` — and were
called from `superRefine`; the locator exclusion is a rule about two fields and needed a
refine plus a cast to reach the type; `params` needed a `.default`; branded fields needed a
`.transform` each, which is why the pegs were `BoundaryParser<T>` rather than `z.ZodType<T>`.
All of that goes.

Behaviour kept, and pinned by the tests that already covered it: every problem reported at
once, paths as `blocks[2].kind`, unknown keys refused rather than dropped, ids unique,
`params` settled to `{}`. Two things improved: a grammar's own message is now reported as it
comes instead of being wrapped, and the throw carries the issue lines in `message`, so one
that escapes to a log is still readable.

`formatTemplateParseIssue` is shared between that message and the middle layer's file-level
report, so a problem reads the same either way.

zod stays a dependency of pl-model-common — `PlRef`, specs, blob and code models all use it.
This is about the document reader only.
A template entry's kind was only verified on the `location` route, inside resolution.
The other two routes went unchecked: `byExactVersion` returns a spec with no kind at
all, so a pinned version installed whatever package it named — and the params check
that follows runs the INSTALLED block's kind parser, holding params written for one
contract against another.

The check moves to the preparation loop, where all three routes have the block's
compiled config and hence its kind. Resolution stops knowing about kinds of blocks it
found: `LocationResolution.kind` and the `FoundPack.kind` behind it are gone, and
locating a block now answers "is there a block here", not "is it the right one".

One call site, deliberately. Resolution could answer it for a location, since
`byLocation` reads the block's description anyway, but that would be two homes for one
invariant with the pinned-version route still uncovered. `kindMismatch` therefore lives
beside `liveParamsForCheck` in template_apply.ts — the other per-entry question asked of
a prepared block — and the caller appends whichever locator the entry carried, so a
pinned version is now named in the message too.

Behaviour change worth noting: a kind mismatch is reported one stage later, so it no
longer arrives together with resolution problems for other entries. Both stages create
nothing, so an apply is unaffected.
A kind's `parseInitializationParams` is a plain function by design — this package
carries no validation library and an author picks their own tool — but the scaffold
undercut that: it seeded `zod` into every new kind's dependencies and generated a parser
written against it. What is meant to be one option among several was the default in
practice, and all thirteen workspace kinds inherited it.

The half every kind actually shares is the shape check: the value has to be an object,
and its keys have to be keys the kind declares. That is now `assertDeclaredParams` in
`@platforma-sdk/block-kind` — an assertion, so a kind reads its fields off the value it
was handed, with no schema and no copy. Per-field checks stay in the kind as ordinary
TypeScript, which is where they belonged: `sum-numbers` now holds references to
`pl-model-common`'s own `isPlRef` instead of restating the shape as a zod schema.

Refusing an undeclared key is preserved, since it was `.strict()`'s whole purpose: a
file saying `number:` for `numbers:` is rejected naming the key, rather than having it
dropped and applying a block that looks configured and is not.

All thirteen kinds converted and `zod` removed from each; twelve of them no longer
inline it into the bundle the registry publishes. `sum-numbers` still does, through
`pl-model-common`, exactly as it did before.
`assertDeclaredParams(value, ["numbers"])` refused any key the kind did not list. It
bought one thing — a typo in a hand-written template file being named rather than
ignored — and paid for it with a list of field names as strings that nothing held in step
with the type: a field added to the contract and read by the parser but missed in the list
turns the kind into one that refuses correct files. A false rejection at apply time is
worse than a typo that surfaces as a blank block.

So the key set is no longer the kind's business. `assertParamsObject(value)` establishes
only what no kind can check safely on its own — that the value is an object. It is worth
sharing because the naive version is wrong three ways: `typeof null === "object"`,
`Object.keys(5)` is `[]`, and `Object.keys(["a"])` is `["0"]`, so null, a number and an
array all read as empty params.

Required fields are still checked, in the kinds, as plain TypeScript — that never moved.
An unexpected key is dropped rather than refused, which is what a parser returning the
params to use already did with it. What such a key can actually mean is a contract from a
different version of the kind, and that is guarded by the version in the entry's kind
reference.

The four kinds that argued for strictness in prose now say what is true instead,
including that `enter-numbers` invites the `number:`/`numbers:` typo and does not catch it.
@AStaroverov

Copy link
Copy Markdown
Collaborator Author

@greptileai

… engine

The engine no longer knows what a reference is. A block's params travel into a template
exactly as the block projected them — no `$ref` wrapper, no marker of any kind — and on
the way back in the block's own bundle is handed the ids of the project being built and
repoints them itself. Which values carry block ids is knowledge of the reference system,
and it now lives in one place: the package the block imports.

`__pl_initializationParams_relocate` is the new facade callback, an extension rather than
a change, called once per entry at construction with the entries created so far. A block
that does not declare it is refused: applying its params as written would wire it to the
ids of the project the template came from.

Rewriting is structural, and that fixes two defects the textual pass had. A value that
merely equals a block id — a `domain` entry, an axis filter — is no longer rewritten with
the reference beside it. And an identifier is rebuilt canonically, so a qualifications map
keyed by identifier comes back sorted the way a fresh project would have built it, which a
string substitution could not do.

`remapColumnIdBlockIds` is restored from bf60d7b, where it was deleted as dead. It walks
all five key forms, every linker hop, and the keys of a qualifications map.

Deleted: template_ref.ts and template_wrap.ts entirely (`TemplateRef`, `toTemplateRef`,
`isTemplateRef`, `remapRefPayload`, `resolveTemplateRefs`, `referencedBlockIds`),
template_validate.ts, `findProjectTemplateV1ReferenceProblems` and its export-side use,
and `liveParamsForCheck` — the pre-flight check now passes the file's params through
unchanged, because they are already live.

The cost, stated where it lands: self and forward references are no longer refused before
the work starts. Reading which entries an entry references means reading its params, and no
block exists until resolution has fetched one. Such a file applies, and the block it
creates reports itself as missing references — the same way a reference to a deleted block
already behaves. An id the map does not name is left alone, which is what makes that
degradation uniform.

Also fixes a live-project bug found on the way: inferAllReferencedBlocks walked object
values and dropped keys, so a discovered column qualified by another block's column lost
that upstream edge from the dependency graph.
Relocation was a callback of its own, invoked immediately before the params-to-storage
one, on the same entry, with the same config, and its result read by nothing but the very
next line. Two things made that worse than untidy: `executeSingleLambda` builds a fresh
QuickJS runtime and re-evaluates the whole model bundle per call, so every entry parsed
its block twice to produce an intermediate value; and neither callback has ever shipped —
`StorageInitialFromParams`, `InitializationParamsValidate` and `InitializationParamsDerive`
are all new on this branch, absent from origin/main, so the facade's "extend, never change"
rule had nothing to protect here.

So `__pl_storage_initialFromParams` takes the id map and does both, in the order it
already implied: repoint the references, check them against the kind, hand them to the
block's init factory. `InitializationParamsRelocate` and `relocateTemplateParamsInVM` are
gone.

Refusing a block that cannot be created from a template still works by the same
mechanism — the callback's absence — and now covers both duties at once, which is the
honest shape: a block that cannot repoint references cannot be created from a template
either.
A real failure from the desktop app: "params are not valid JSON: unexpected token:
'undefined'", against an entry whose params were `{numbers: [3]}` and held no reference at
all. The params were fine. What was missing was the second argument — the id map — because
the middle layer applying the template was packed before it learned to send one, while the
block it invoked had been rebuilt after. `JSON.parse(undefined)` throws exactly that in
QuickJS, and folding both reads into one try sent the reader to the file to look for a
mistake that was not there.

The two arguments now fail separately, because they fail for unrelated reasons: params come
from the file and a bad one is the author's mistake, while the id map comes from the caller,
so the only way it arrives unreadable is a caller too old to send it. That message says so,
and names the fix — refresh the build.
A block stores `{ __isRef: true, blockId, name }` and an export writes exactly that, which is
right for a machine and miserable to type. `TemplatePlRef` — `{ block, name }` — says the same
thing without the marker, and is expanded into a `PlRef` on the way in, inside the block's own
bundle, before the kind's parser and before init. So a kind's params contract stays written
against `PlRef` alone and never learns this type exists, and both spellings reach a block
through the same step and cannot diverge.

`block` is a template-local entry id and nothing else. An earlier draft also took a number, as
a position in `blocks`, and it did not survive contact: a position needs the document's order,
which meant carrying the whole entry list into the VM, turning the id map into an ordered list
covering entries not yet created, and adding that argument to the pre-flight callback too — for
a spelling that saves nothing a hand-written file cannot already say. A readable form has to
carry the same information as the form it stands for; then expanding it is a rewrite, not a
lookup, and needs nothing but the value.

`expandTemplateRefs` is named for the job rather than today's only case, and says what a second
readable form has to respect: expand bottom-up, because a wrapper key holds its source as a
canonical string, and canonicalize what it builds. `TemplateCUId` / `TemplateCUKey` for the
filtered, discovered and overridden keys are the intended next ones.

Expansion runs in the pre-flight check as well as at construction. Without it a kind declaring
a reference would reject every hand-written file: `{ block, name }` is not a `PlRef`.

Also clears four docstrings and two assertions still describing the `{ $ref: … }` wrapper,
which stopped existing two commits ago.
@AStaroverov
AStaroverov enabled auto-merge August 18, 2026 16:43
@AStaroverov
AStaroverov added this pull request to the merge queue Aug 18, 2026
Merged via the queue into main with commit 2631958 Aug 18, 2026
18 of 19 checks passed
@AStaroverov
AStaroverov deleted the MILAB-6648_block-kind-prototype branch August 18, 2026 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants