The solution package format
What a solution package may contain, and the four gates into the library.
It is the same file the platform's own tests are written against — LGFlow/docs/reference/package-reference.md — rendered here rather than copied, so it cannot fall behind the software it describes. It is written for somebody working on the platform and uses the internal project names: LGFlow is Symphonix Core, LGStudio is Symphonix Studio, and LGMonitor or LGStudioMonitor is the Symphonix Control Panel. Names that begin LGFlow and appear in code — LGFlowValidationError and the rest — are exported identifiers and are spelled exactly as the software spells them.
*What a solution package may contain, and what an installation does with one.
Package schema 1.0. This is the authority for the format, the way
dsl-reference.md is the authority for a flow; src/packages/ is held to it by
test/packages.test.js.*
A solution package is a directory. It holds one or more LGFlow flows, their tests, their documentation and their assets, plus three files that say what it is and whether it is intact. Importing one does not deploy, generate, execute, install or activate anything.
And an agent project is not one of these#
Both are "a folder holding an agent's files", which is why the difference is written down here rather than worked out twice.
| agent project | solution package | |
|---|---|---|
| what it is | somewhere somebody works | something somebody ships |
| says what it is in | .lgflow/project.json — a name, a description, who has worked in it | solution.json — a schema version, an author, what it needs |
| is it intact? | nobody asks; it is a working folder | integrity.json and signature.json, and four gates on the way in |
| what is in it | flows, generated code, node_modules/, a .env — whatever a running agent needs | flows, tests, docs, assets, and never code |
| how it travels | copied, cloned, or deployed to | imported, through the gates |
| who names it | the person working in it | the author who published it |
A solution package becomes an agent project when somebody instantiates it: the flows land in a folder, code is generated there, and that folder is a project like any other from then on. Nothing goes the other way — there is no button that turns a working folder into a package, because a package has to be authored rather than swept up.
lgflow/project is the module for the first; src/packages/ is the second.
There is no archive format in this version. A package is moved between
installations as a directory, by whatever file channel the organisation already
uses. ZIP was considered and refused: the dangerous properties — a central
directory and local headers describing the same entry, ZIP64, data descriptors —
are in the container rather than in any one reader. If single-file transport is
added it will be tar.gz.
The shape of a package#
customer-support/
solution.json what it is
integrity.json every digest, and the inventory
signature.json optional; detached; covers integrity.json
README.md
docs/getting-started.md
agents/support-agent/flow.json
tests/support.test.json
evals/support.eval.json
assets/logo.pngNothing else. The allowlist is in src/packages/allow.js and it is an
allowlist, not a denylist: a path that matches no shape below is refused.
| path | what it is |
|---|---|
solution.json | the manifest. Required |
integrity.json | digests and inventory. Required |
signature.json | a detached signature |
README.md | the package's own front page |
docs/<name>.md | documentation |
agents/<id>/flow.json | one agent's flow |
tests/<name>.test.json | an LGFlow test file |
evals/<name>.eval.json | an LGFlow eval file |
assets/<name>.{png,jpg,jpeg,webp} | images. Raster only |
Refused, each for a reason:
- Any executable —
.js,.mjs,.ts,.sh,.ps1,.bat,.py. Nothing in a package is ever run, so an executable file can only be a mistake or an attack. A flow cannot contain code: every executable unit in LGFlow is a description, and the code is written by the receiving installation's own generator. .svg— an SVG is a document that can carry script, and an asset reaches a browser throughpage.logo. The worst an asset here may do is look wrong, which is the same decision already recorded forpage.cssand fetching.package.json,package-lock.json,node_modules/— npm dependencies are declared in the manifest and satisfied by the deployment. Apackage.jsoncarriesscripts, which is an install script.- Any dotfile,
.envincluded, so a secret cannot arrive by that route at all. - Symlinks and hardlinks, checked with
lstatbefore any file is read. .., absolute paths, drive letters, UNC paths, NUL bytes, and — because this product runs on Windows — reserved device names (CON,NUL,COM1…, with or without an extension) and trailing spaces or dots, which Windows strips, soflow.jsonandflow.jsonwould be one file on disk and two declarations in an inventory.
Ceilings, from LIMITS: 2000 entries, 8 MB per file, 64 MB unpacked, 8 path
segments, 180 characters per path.
solution.json#
{
"schemaVersion": "1.0",
"id": "customer-support",
"name": "Customer Support Agent",
"version": "1.0.0",
"kind": "solution",
"category": "Customer Service",
"publisher": "LGFlow",
"description": "Answers customer questions, looks a customer up and raises a ticket.",
"tags": ["support", "crm"],
"compatibility": { "features": ["approval", "conversation", "llm", "mcp", "state", "tools"] },
"agents": [
{ "id": "support-agent", "flow": "agents/support-agent/flow.json", "primary": true }
],
"dependencies": {
"mcp": [{ "alias": "crm", "requiredTools": ["getCustomer", "createTicket"] }],
"env": ["OPENAI_API_KEY"],
"packages": { "docx": "^8.5.0" }
},
"configuration": {
"COMPANY_NAME": {
"type": "string",
"required": true,
"label": "Company Name",
"maxLength": 80,
"bindings": [
{ "agent": "support-agent", "node": "greet", "field": "prompt", "placeholder": "COMPANY_NAME" }
]
}
}
}| field | |
|---|---|
schemaVersion | "1.0" or "1.1". An unknown version is refused, not parsed as best we can — and a field a version predates is refused too, by name, so that an older installation's refusal and this one's agree |
id | lowercase letters, digits and hyphens. With version, identifies the package |
version | an exact semantic version. Not a range: a package version is a thing, not a constraint |
kind | "solution" |
publisher | a label, and never evidence. See Trust |
compatibility.features | named capabilities. See below |
agents[] | id, flow (which must be agents/<id>/flow.json), primary. Exactly one primary |
dependencies | declared, never resolved or installed |
configuration | typed parameters and where they bind |
compatibility.features, and why there is no minDslVersion#
A package names the capabilities it needs, from the vocabulary in
FEATURE_VERSIONS. There are 43:
agent, approval, approvalExhibits, approvalNeeds, approvalWhy,
ask, askingForFields, building, colors, conversation, database,
describe, dynamicFields, fanout, files, forms, identity,
interactiveSubflow, knowledge, layout, limits, llm, logo, mcp,
onError, onErrorSkip, packages, page, read, remembers, retry,
scopedLimits, shell, state, structuredOutput, subflow,
suggestions, tools, triggers, users, views, who, work.
*This said 36 and named 36 until 2026-11-05, when it was read against
FEATURE_VERSIONS while closing the version that released the third engine.
Seven were missing — approvalExhibits, approvalNeeds, approvalWhy,
building, database, knowledge and onErrorSkip — including the two most
load-bearing names a package can carry today, since database and knowledge
are what the two newest cards in the library are for. Both halves had
rotted: the list and the number counting it. A list typed into a live
reference is a list that disagrees with the code, and the count beside it
agrees with whatever somebody typed; test/reference.test.js now holds both
directions and the figure, so a feature added to the compiler fails here until
this paragraph learns it.*
compatibility.minDslVersion is refused by the schema. A floor on a version
number is wrong in both directions: it turns away a package that uses nothing
newer than the installation has, and it admits one that needs a feature the
installation's particular build lacks. Capabilities are named precisely so that
silence is not mistaken for a "no".
The list is derived from the flows by buildPackage, not typed by a person:
a declaration nobody maintains is wrong within one version. It is deliberately
conservative — a capability is claimed only where the construct that needs it is
structurally visible — because under-claiming is caught by the third check
below and over-claiming would refuse a package that would have run.
One under-claim is known and is not deliberate (row 25.22, 2026-11-05).
featuresOf walks the flow and the flow's nodes, and never
flow.edges — so an edge's own versioned fields, limits (0.2) and
limits.per (0.3), are claimed by nothing. A package built with either
passes the second check and then meets the third as a compiler refusal naming
a field the installation does not know, rather than as this needs 0.3.
Found by adding two conditional edges to a shipped card and watching
compatibility.features come out byte for byte the same. It is carried
rather than patched because the fix is a name: an edge's limits and a
node's limits are not the same capability, and limits and scopedLimits
are already spent on the two homes that are walked.
An installation checks three things, all three required:
- the package schema version is one it understands;
- every name in
featuresis one it has — asked as explicit membership ofFEATURE_VERSIONS, and then whether each flow's own declared version is high enough for it; - every flow validates, through the installed
validateFlow(). Not a copy of it.
dependencies#
Declared and reported. Never resolved, installed or connected.
mcp—aliasandrequiredTools. An MCP server is declared, never described:transport,command,url,headers,tokenand the like are refused, because transport, address and credential belong to the deployment and come from.env. A person maps each alias to a registered connection during instantiation.
It may also say toolsChosenAtInstall (schema version "1.2"),
and that one is a grant rather than a description: it says the tools in
the shipped flow are the ones this solution was *authored and tested
against*, and that the installation may replace them with the person's own
choice. Instantiation then takes a tools argument — keyed by kind and
then by the package's own alias, the same shape as mapping, so
{ mcp: { crm: ["findCustomer", "openTicket"] } } — substitutes the chosen
names into requires.mcp.<alias>.tools before the mapping renames
anything, and re-validates the finished flow, so a choice that leaves
out a tool one of the flow's nodes names is refused like any other invalid
flow. A flat { crm: [...] } is refused with
TOOL_CHOICE_NOT_BY_KIND, not read as mcp: that was the shape until
2026-11-06, and the argument for changing it — along with the argument for
the flat one, which was a real argument — is in checkToolChoice's header
in src/packages/mapping.js.
Absent means no. Everywhere else in this platform nobody has said must not be read as yes or as no; here it may be, because this is not a fact somebody might not have been asked but a permission the author either gave or did not, in the manifest they signed. A package built before 1.2 cannot have its tool list replaced by whoever installs it.
Who fills tools is a screen, and it narrows. In LGStudio's *Use this
solution* step — the mapping, the fourth of the five MCP screens — an
alias carrying the grant gains a list of that server's tools to tick. The
package's requiredTools is the floor, ticked and not untickable; what
the server itself named is the ceiling; the shipped list is the
default, so pressing Create without opening the step writes exactly the
flow the package ships. A required tool the server was *asked for and did
not name* is refused there, with both facts in the sentence — which is the
whole value of holding two lists, and is refused on the screen because
checkToolChoice knows what a package requires and has never heard of a
server.
The ceiling is measured, not declared: an agent asks its own MCP server and reports what it named (monitoring 0.19), and the board keeps it. Where nobody has asked, there is no ceiling — which is nothing known, not an empty one — and the step falls back to the solution's own list as shipped rather than drawing a choice between names nothing has checked.
And the choice is carried to the board, where it is a switch rather than
a ceiling — § 30, 2026-11-07. What goes into the flow is the list the
generated client is built for; whether the agent may call any of it is a
subscription, the second of the five MCP screens, and a board that has
a row for an agent it was never told anything about answers nothing by
the fail-closed rule in docs/mcp-subscription.md. Both halves were right
and ticking twelve tools produced an agent built for twelve and switched on
for none. So planProject reports chosenTools — per agent, under the
mapped name, and only for an alias the manifest opened and somebody
ticked — and instantiate returns the flow ids it minted, which is what
a board addresses an agent by. A caller with a board can then subscribe
what was chosen; LGStudio's install route does, as the person, so the
board's own access.manage still decides and a refusal is said out loud.
Nobody ticking anything reports nothing, which is what keeps this from
widening: the authored list is already in the flow, and restating it as a
subscription would be an installation claiming a decision nobody made.
It exists because a package's flows are validated as shipped (gate 4
below), so a solution meaning call whatever this installation has cannot
just leave the list out — a flow with no tools under its server is refused
at import. The alternative considered was a DSL form meaning *chosen at
install*; it was rejected because it would put a flow in the library that
cannot be deployed as it stands, and would move DSL_VERSION for a
field that adds no language feature.
env— names only. A value here is refused rather than filtered out, because filtering would mean a publisher believed they had shipped it. And not a name derived from adatabaseorknowledgealias: those areDATABASE_<ALIAS>_*andKNOWLEDGE_<ALIAS>_*, derived from the declared name, so mapping the alias moves them. A manifest that listsDATABASE_DB_URLis telling the installation to set a variable that will not exist oncedbis mapped ontoSales— the generated client readsDATABASE_SALES_URL, readiness asks for the name the manifest gave, both sides look right on their own, and the deployment is the thing that does not work. Refused asENV_NAME_MOVES_WITH_THE_MAPPINGrather than silently rewritten, because rewriting would mean the compiler deciding what a publisher meant.packages— npm names and ranges, shown and checked, never installed.database—aliasanddescribe, and nothing else. Schema version 1.1. A database is declared the way an MCP server is: the alias is what the package calls it,describesays what it holds, and where it is, which engine it is, who connects to it and what that credential may do are the deployment's.host,port,user,username,password,credential,dsnandengineare refused by name, as are the MCP words. A person maps each alias to a connection registered on the board during instantiation.knowledge—aliasanddescribe. Schema version 1.1, and the same shape for the same reasons. The words it refuses are the folder ones —folder,path,dir,root,documents— because a collection's address is a folder and there is nowhere in the DSL to put one: where the documents are comes fromKNOWLEDGE_<NAME>_FOLDERon whichever machine the runner is on. That is also what stops a package choosing what gets indexed.
Every requirement is reported in one of three states: present, missing,
or unchecked. The third is what an installation with no board to ask must say,
because an empty list does not read as I could not ask — it reads as *there
are none*.
Which version a dependency kind needs#
| dependency kind | needs schemaVersion |
|---|---|
mcp | 1.0 |
env | 1.0 |
packages | 1.0 |
database | 1.1 |
knowledge | 1.1 |
*This table is the one a test reads, and it is a table rather than a sentence
for that reason.* The first version of that test matched the kind and the
version by proximity in prose, and a mutation that changed database's
version to a word survived it — the pattern was satisfied by the paragraph
next to it. A check that reads an explanation is a check the explanation can
satisfy by accident.
And which version a field inside one needs#
| dependency field | needs schemaVersion |
|---|---|
mcp[].toolsChosenAtInstall | 1.2 |
A second table because it answers a second question. The one above is read by
the kind — may a manifest at this version say database at all. This one is
read by the field — *may an mcp entry, in a format that has had mcp since
1.0, say a new word inside it*. Without it the builder would stamp 1.0 on
a package carrying the flag and hand it to an installation that has never
heard of the word, which would instantiate without replacing the tool list
and write a project wired to whichever tools the author happened to test
against.
It is read by presence, not by value, so a manifest that spells out
"toolsChosenAtInstall": false is stamped "1.2" although it means exactly
what leaving the field out means. That is the documented cost of keeping this
a table of versions rather than a table of predicates.
A manifest declaring "schemaVersion": "1.0" and a database dependency is
refused with DEPENDENCY_NEEDS_SCHEMA_VERSION — named, never inferred,
which is the rule this platform already applies to a flow's features and to an
agent's capabilities. A build that happened to understand the field must not
accept a manifest that does not claim to be allowed to say it, because the
version is the only thing that tells an older installation to refuse.
A publisher does not choose this field. buildPackage stamps the
lowest version that admits everything the package actually says, derived
from its own dependencies — the same rule as compatibility.features, and
for the same reason: a declaration nobody maintains is wrong within one
version. Lowest and not newest, deliberately: a package that needs nothing new
stays at 1.0 and can still be read by every installation there is, where
stamping the newest version on everything would make each release of this
compiler quietly orphan the packages it built. An empty database: [] says
this package needs no database and does not bump it.
And an older installation does refuse, whole. It has only "1.0" in its
PACKAGE_SCHEMA_VERSIONS and answers UNKNOWN_SCHEMA_VERSION. That is the
right answer and not a limitation: a best-effort parse that read the fields it
recognised and dropped dependencies.database would hand somebody a project
whose flow names a connection nobody was ever asked to map — a thing that
opens, looks finished and answers nothing. database and knowledge
arrived in the same version deliberately, because they are one idea and two
formats learning it a version apart are two places that will one day disagree
about what an alias is.
A 1.1 manifest's dependencies, in full — the example above stays at 1.0
because a package that needs nothing new should not claim a version that makes
older installations refuse it:
{
"schemaVersion": "1.1",
"dependencies": {
"database": [
{ "alias": "db", "describe": "A relational database this agent may read. What it holds is asked of the connection itself at the start of every run." }
],
"knowledge": [
{ "alias": "handbook", "describe": "The policies this agent answers from." }
],
"env": ["OPENAI_API_KEY"]
}
}configuration and [[PLACEHOLDER]]#
A parameter may change a flow's words and never its shape.
"bindings": [
{ "agent": "support-agent", "node": "greet", "field": "prompt", "placeholder": "COMPANY_NAME" }
]- A node is addressed by
id. Anindexis refused: it would bind a parameter to where a node sits in an array, which any edit reorders. fieldis one ofprompt,description,ask, one key underargs(args.city), orapproval.ask. Nothing structural — nottype,output,handler,tool,startoredges. These are the five places a flow keeps words a person reads, and each was checked against the DSL: a closed set that named a field the DSL does not have would invite somebody to use it.- The delimiter is
[[NAME]], deliberately not{{name}}, which is LGFlow filling a channel from state at run time. A placeholder containing{or}is refused, so the two cannot be confused even by accident. - A value may not contain
{{or}}(VALUE_LOOKS_LIKE_INTERPOLATION). This is the other half of keeping the two apart: without it, typing{{answer}}into a field labelled "Company Name" writes a flow that reads a channel at every turn, which the flow's author never wrote and the person filling in the form did not mean. - A placeholder can never sit inside a template.
{{[[CHANNEL]]}}is not something a package can ship, because a package's flows have to validate *as shipped* and that is not a template LGFlow can fill. - Both directions are checked at publish: a binding whose placeholder is not in
the field is an error, and a
[[TOKEN]]with no binding is an error — the second because the flow would otherwise deploy with[[COMPANY_NAME]]in a prompt. - Substitution is one pass, whole tokens, on the parsed document. A value
containing
[[OTHER]]or{{answer}}is inserted literally and never looked at again, so a parameter cannot smuggle in a second substitution. - After substitution and dependency mapping, the result is validated again.
Starter questions, and why a solution about a database ships none#
A flow's suggestions are not prose about the agent — they are the buttons a
person sees in the chat, and a starter question is only useful when it is
concrete enough to name something real. For an agent whose subject is the
installation's own data that is a problem with no middle: a question kept from
wherever the solution was built ships one installation's table names to
everybody who installs it, and an invented domain-free one is a button that
runs against nothing.
So a solution whose starter questions would have to name the installation's own data ships none, and the field is absent rather than filled in with something plausible. The answer for those agents is that the running agent writes them from the schema it already asks for on every run — the only shape that cannot go stale, because it is derived from the database in front of it rather than from the one the publisher happened to have.
This is not a ban on suggestions in a package. A solution whose starters
name nothing of the installation's — "I need help with an order" — ships them
as it always could. The rule is about questions that can only be written by
knowing somebody's tables.
And a solution about a body of documents ships none for the same reason and
has no remedy at all — § 17.4, checked against this rule rather than decided
again. The sentence above offers one way out: *the running agent writes them
from the schema it already asks for on every run*. A collection has no schema
and is never asked for one, deliberately — src/generate/knowledge.js has the
argument, and 0.31's rule that a flow never learns where its documents are is
what the absence protects. So the Document Answering Agent's starters would
have to be written from the alias's describe, which means a model call on a
page load, inventing questions about documents nobody has read, at a cost
nobody has measured. The field is absent, and for this kind of solution it
stays absent: the first thing a person does with a document agent is ask it
something out of their own corpus, and a button that guessed at that would be
wrong in a way that looks like the agent being wrong.
The two shapes that were weighed and rejected, so nobody re-derives them:
- A
configurationparameter the wizard asks for. It would be the first list-valued parameter in a surface whose whole rule is *a parameter changes a flow's words and never its shape*, and it moves the work to every installing person — who has the schema in front of them and still has to invent four good questions by hand. - Omit it and let the installation add its own. This is what absence looks like today, and it is the safe half rather than the answer: nobody goes back to fill it in, so the page simply has no starters.
The derivation is not built. Until it is, an agent of this kind opens with an empty suggestion row — which is a worse page than one with four good questions and a better one than four questions about tables this installation does not have.
Mapping an alias#
requires.mcp, requires.database and requires.knowledge are each keyed
by name — { "crm": { describe, tools } }, { "db": { describe } } — so
mapping a package's alias onto one of this installation's own is renaming
that key.
A mapping is keyed by kind, and a flat one is refused with
MAPPING_NOT_BY_KIND rather than read as mcp:
package says: requires.mcp.crm mapping: { mcp: { crm: "crmProd" },
requires.database.db database: { db: "AccountsReceivable" } }
project gets: requires.mcp.crmProd
requires.database.AccountsReceivableIt is keyed by kind because a package may legitimately call its MCP server
and its database the same thing. They are different namespaces in the
generated code — a server arrives as services.crm, a database as a binding
in src/database/index.js reached through askDatabase("crm", …) — and the
compiler refuses a shared name only across services, settings and mcp,
which a database is not part of. A flat map asked to rename crm would rename
both, silently, and the person who typed one name into one field would get
two things repointed. Two shapes for one argument is how the next reader comes
to believe the wrong one, so the flat form is refused and not accepted as a
shorthand.
What moves with the key differs by kind, and MCP is the simple one:
| kind | what else moves |
|---|---|
mcp | the services.<name> mentions in the flow's prose. Nothing in a node — a node refers to a tool, never to the server |
database | each database node's from |
knowledge | each knowledge node's from, and each index node's into |
Those node fields are rewritten against the node's type, never by looking
for the field's name. from is also an edge's field, where it means a
node; a walk that rewrote every from it found would repoint the graph rather
than the connection — a flow that still validates, still deploys, and runs its
nodes in an order nobody wrote.
A mapping is refused when:
MAPPING_MISSING | an alias nobody mapped |
MAPPING_IS_AN_ADDRESS | a URL, a drive letter or a path. A name, never where it is — transport, address and credential belong to the deployment and come from .env. Same judgement as 0.23, asked of the mapping |
MAPPING_NOT_USABLE_AS_A_NAME | crm-prod. All three kinds become an identifier in generated code, so the name has to be able to be one — this reads perfectly well to a person and cannot be |
MAPPING_NOT_REGISTERED | this installation has no such connection. Only when it could be asked; with available.<kind> null the mapping stands and nothing claims it was checked. There is no exemption by kind — the check runs for mcp, database and knowledge alike, wherever something answered. What differs is how often anything does: a collection may be a row on the board, which a board with a Collection Manager can list and which is then checked like any other name, or a folder named by KNOWLEDGE_<NAME>_FOLDER on whichever machine the runner is on, which nobody can be asked about and which is a perfectly ordinary way to hold one. So unchecked is the common answer for knowledge and the degraded one for a database — and in neither case may it be read as there are none |
MAPPING_COLLIDES | two aliases onto one name, within a kind — it would merge two declarations, joining an MCP server's tool lists or silently picking one of two describe sentences. Per kind, not across them, for the reason the mapping is keyed by kind at all |
MAPPING_NOT_BY_KIND | a mapping that is not keyed by mcp, database or knowledge — usually the old flat shape |
MAPPING_UNKNOWN_ALIAS | a mapping for an alias this package does not declare under that kind |
REQUIRED_TOOL_NOT_DECLARED | the manifest promises a tool no flow declares under that server — the generated client is built from the flow's declaration, so that call would compile and answer nothing |
What the mapping step deliberately does not check is whether the resulting
flow is legal. That is the validator's question. A flow with a service called
crmProd is valid, and mapping crm onto crmProd makes it invalid
(DUPLICATE_REQUIREMENT — handlers would read both as services.crmProd); the
re-validation below catches it, and a second copy of that rule here would be the
copy that stopped agreeing with the compiler.
Instantiation#
The result is an ordinary LGFlow project in a folder the person owns: a flow
file per agent, plus the package's README.md, docs/, tests/, evals/ and
assets/. It has no live dependency on the package, nothing updates it
automatically, and deleting the package changes nothing about it.
What does not come along: solution.json, integrity.json and
signature.json. The result is a project, not a package, and a folder carrying
a manifest would read as one.
The order, and where it stops:
- re-verify the package — admission was in the past;
- check the values against the typed parameters;
- check the dependency mapping;
- per agent: substitute, map, then validate the result with the installed validator — what has to be a valid flow is the one that lands on disk;
- refuse a destination that already has something in it;
- write into a staging directory, then one
rename; - write provenance.
Nothing is written until step 4 has passed for every agent. A package with two flows where the second fails writes neither: half a project is worse than none, because it opens, looks right, and is missing a flow nobody mentioned.
**Step 5 said exists until 0.53, and it meant has something in it. The
two part company in one place, which turns out to be the first thing anybody
does: name a folder for a project, then reach for a solution to fill it. The
studio's New project** writes .lgflow/project.json and nothing else, so
the folder is there and is empty — and exists answered that opening act
with a project is never written over, about a project that was not there.
A destination is now refused when it holds anything but a .lgflow/ carrying
the project's own name; a flow, a README.md, a .lgflow/deleted/ of
somebody's removed flows, and the refusal is exactly what it was. Where the
destination is bare the staged entries are moved into it, merging
.lgflow/ so that the project keeps its name and gains its provenance
sidecar — the rename is still the publication, there are simply several of
them, because no platform renames a folder over one that is already there.
planProject() does 1–4 and returns the finished flows without touching the
disk, so a wizard can show exactly what would land. instantiate() does all
seven.
requireTrusted is the caller's to set, not this module's: a vendor package
must be signed and a draft in somebody's own workspace must not have to be, and
that is a policy rather than a property of the format.
Provenance#
A sidecar at .lgflow/provenance.json, keyed by the flow file:
{
"schemaVersion": "1.0",
"instances": {
"support-agent.json": {
"instanceId": "…",
"createdAt": "2026-10-12T…",
"createdBy": "Shmuel.tal",
"package": { "id": "customer-support", "version": "1.0.0", "scope": "workspace", "integrity": "<64 hex>" },
"agent": "support-agent",
"flowBuildAtCreation": "<64 hex>",
"parameters": ["COMPANY_NAME"],
"mcp": { "crm": "crmProd" }
}
}
}Two things it is not.
It is not a security credential. No decision about what anybody may do is made from it. It is a JSON file in a folder its owner can edit, so anything in it is the applicant's own claim about themselves; trust is a verdict about a package's bytes and a local trust store, re-asked rather than remembered.
It is not deployment state. It says where the project came from and nothing
about whether it runs. manifest.json from the generator and running.json
from the lock live in the same directory precisely because the three answer
different questions.
And it holds no parameter values — only the names of the parameters that were filled in. The value went into the flow, which the person owns and can read; a second copy here would be a second place for it to leak from, and somebody will type a secret into a parameter whatever the label says.
flowBuildAtCreation is the flow's digest less who touched the file —
0.56. It is what customised is a comparison against, and it excludes
createdBy and lastUpdatedBy (WHO_TOUCHED_IT in src/constants.js,
the same list buildIdentityOf takes) because the studio writes
lastUpdatedBy on every save by a signed-in person: a digest over the whole
flow calls a project edited the moment somebody opens it and saves,
and it did — on two of three real projects, where dropping that one key made
the digest equal the record to the character.
It replaces flowDigestAtCreation, which held the whole flow's digest
and is no longer written. A sidecar carrying only the old field is read
normally and answers customised: null — not known — which is what the
three-way answer exists for: an installation that cannot ask the question
says so rather than guessing. The schema version does not move for this,
because the shape is otherwise identical and a bump would report
PROVENANCE_OTHER_VERSION against every sidecar already on disk.
| situation | what happens |
|---|---|
| no provenance | completely ordinary — that is most flows. known: false, why: "NO_PROVENANCE", and no problem reported |
| corrupt provenance | reported (PROVENANCE_UNREADABLE) and then treated as absent. Nothing in LGFlow is gated on this file, so losing it costs a comparison and not a project |
| a sidecar from another version | read anyway, with PROVENANCE_OTHER_VERSION. Discarding it would lose the only record of where the project came from |
| a direct edit on disk | expected — the project is the person's. customised: true, by comparing the logical digest, so reformatting and reordering keys do not count as a change |
| a copied project | provenance travels with it, which is right: the copy did come from that package. Two folders then assert one instanceId, which findDuplicateInstances() finds and reseatInstance() tidies without forgetting the source |
duplicate instanceId | detectable only by walking, because nothing keeps a register of instances — which is also why an instanceId must never be a key for anything that matters. It is a label for a person reading two folders |
| the package is deleted | the instance is unaffected. compareWithPackage() returns sourceGone: true and comparable: false, and sameVersion: null — a screen rendering "no update" there would be claiming it had looked |
| a future update | compareWithPackage() gives sameVersion, packageChanged (the same version with different bytes, which a registry forbids and a folder does not) and customised. The three-way merge is deferred; this is the metadata that keeps it possible |
integrity.json#
{
"schemaVersion": "1.0",
"algorithm": "sha256",
"inventory": "exhaustive",
"files": {
"solution.json": "<64 hex>",
"README.md": "<64 hex>",
"agents/support-agent/flow.json": "<64 hex>"
}
}A digest covers a file's exact bytes on disk. No canonicalisation, no newline normalisation: canonicalising before hashing would mean the bytes verified are not the bytes later read.
Digests are full SHA-256, 64 hex characters. The 16-character truncation in
src/generate/manifest.js keeps its own job — was this generated file edited —
and is never used here, because 64 bits puts a deliberate collision within
reach and that manifest never had to resist one.
"inventory": "exhaustive" is a claim with teeth, checked both ways: a file
present but unlisted is refused (nothing covers it), and a file listed but
absent is refused (the package is not what its publisher built). Anything else
verifies a subset and reports success.
integrity.json may not list itself, and must list solution.json.
signature.json, and trust#
{
"schemaVersion": "1.0",
"algorithm": "ed25519",
"keyId": "vendor-2026a",
"signedAt": "2026-10-12T09:00:00.000Z",
"value": "<base64>"
}Detached, and it covers integrity.json's exact bytes and nothing else. One
signature therefore covers the whole package:
signature.json -> integrity.json -> solution.json and every other fileOne direction, no cycle. This is why no file holds a digest of itself.
The trust store is local configuration — nothing here reaches the network, which is what lets an air-gapped installation verify a vendor package:
<library root>/trust/
keys/<keyId>.json { keyId, publisher, publicKey, notBefore, notAfter? }
revoked.json { revoked: [{ keyId, revokedAt, reason }] }- The publisher of record is the key, never the manifest. Anybody can write
"publisher": "LGFlow"in a JSON file. The publisher shown is the one on the trust entry for the key that actually verified; where the label disagrees the package is still admitted — a mismatched label is not an attack on its own — and the label is shown struck through. - Rotation compares
signedAtagainstnotBefore/notAfter. A key rotated out last month still verifies what it signed while it was valid. That is what rotation means. - Revocation is evaluated at verification time, so a revoked key voids every package it signed, whenever signed.
- Unsigned, signed by an untrusted key and signed by a revoked key are
three different verdicts, reported as
UNSIGNED,KEY_NOT_TRUSTEDandKEY_REVOKED. A valid package can be untrusted: they are different questions.
The registry on disk#
<library root>/
.staging/<uuid>/ one import in progress; swept on startup
vendor/<id>/<version>/ immutable once present
organization/<id>/<version>/
workspace/<id>/<version>/
trust/
index.json cache; derived; no authorityThe rename is the publication. An import copies into .staging/<uuid>/,
passes four gates there, and is then moved into place with one rename. Before
it the package is invisible; after it, complete. There is no window in which
half a package is listed.
There is no lock, deliberately. The rename is the mutual exclusion: its
target must not already exist, so of two concurrent imports of the same id and
version exactly one wins and the other is told DUPLICATE_VERSION. Duplicate
detection and concurrency control are the same mechanism, and a lock file would
add the one failure a rename cannot have — outliving what it protected.
Crash recovery is a delete. Anything in .staging is unfinished and
unreferenced, because visibility is the rename.
The index is not a source of truth. index.json exists so a catalogue does
not walk the tree on every request. Nothing is admitted, verified or
instantiated from it. Where it and the directories disagree, the directories
win, it is rebuilt, and the disagreement is reported. It deliberately does not
cache whether a package is trusted: trust is a verdict about bytes as they are
now and a revocation list that changes, so a cached trusted would be the one
field that could be dangerously stale.
Taking a version off the shelf#
removePackage({ root, scope, id, version }), and it is the mirror image of the
import: one rename makes it gone, and the deleting happens afterwards under
.staging. Removing a directory in place would mean that for as long as the
delete takes, the package is still a package — with some of its files, and
therefore a failing digest, which is indistinguishable from one somebody
tampered with. The rename is the publication, so the rename is the
un-publication. A crash between the two leaves a directory under .staging,
which is what the sweep already exists for.
The id's own directory goes with its last version, because a shelf listing it
would show a solution with no versions. By rmdir, which refuses a directory
that is not empty: anything still in there is a version this did not remove.
scope, id and version all arrive from a caller and all three become path
segments, so each is checked against the shape the library's own directories
have — ^[A-Za-z0-9][A-Za-z0-9._-]*$ — before a path is built from them,
rather than resolved and then compared. A version of .. joined onto a root
is how a remove becomes a remove of something else.
It does not touch any project made from the package. Those are ordinary LGFlow projects with no live dependency on the library, which is the whole claim instantiation makes; the provenance sidecar goes on naming a version that is no longer on the shelf, and that stays true — it says where the project came from, not where it can still be got.
This is the only way a published version changes. A version is immutable, so correcting a package is: remove, then import again. Before this existed there was no way off the shelf at all, and a fix meant inventing a version number for it.
The four gates#
| 1 | paths and entries — the allowlist, lstat before read, ceilings, required files, magic bytes |
| 2 | digests and the exhaustive inventory, both directions |
| 3 | the signature, the trust store, the validity window, revocation |
| 4 | the manifest schema, the named capabilities, every flow validated, the bindings, the secret scanner |
A caller gets every reason a package was refused, not the first.
Re-verification#
Admission is in the past. Bytes on disk can be edited afterwards and a key can
be revoked afterwards, so verifyInstalled() re-asks — on demand, and at the
moment before a package's contents are copied into somebody's project, which is
when the answer matters.
The secret scanner is advisory#
It refuses two things outright, because neither can be a false positive: a PEM private key block, and a credential prefix only ever issued as a live credential. Everything else — connection strings, internal hostnames, developer paths, email addresses — is reported and not refused.
A package it does not flag is not a package it has cleared, and a screen
rendering "no findings" as a tick has turned a partial check into a false
assurance. instantiatePlan() returns scannerIsAdvisory: true so the UI has
no excuse.
A finding never quotes the value — not truncated, not masked. The report says what kind of thing was found and where, because it is written into a journal a board displays, and a secret never appears in an error, a trace or a journal.
What import and instantiation do not do#
They write files and read files. No deploy, no generation, no npm install, no trigger activation, no MCP registration. A test asserts the generator and the deployer are never reached.
This must not be rounded up to "nothing ever executes." A flow carries
descriptions, and a description is a prompt to a code-generating model. A
package cannot ship code, but it can ship text asking a model to write code,
which the receiving installation will generate — and generation is not inert:
src/generate/verify.js runs each generated unit once against sample state.
Three things stand between that and a running agent, and all three must be kept:
instantiatePlan()returns the verbatimdescriptionof every tool and handler, for the security step. Never a summary — a summary of attacker-controlled text is written by a model reading attacker-controlled text.- Generation is a separate step a person starts, and gate 1 of
verify.jsalready refuses a unit importing anything butnode:built-ins. - Deploy is never automatic.
This is the one path from a package to executed code. It is mitigated, not closed.
The programmatic surface#
import {
buildPackage, signPackage, // publishing
verifyPackageAt, verifyInstalled, // checking
importPackage, removePackage, // the registry
readCatalogue, rebuildIndex, sweepStaging,
instantiatePlan, // what a wizard shows about a package
analyseDependencies, checkMapping, // the mapping step
planProject, instantiate, // what would land, and landing it
describeInstance, compareWithPackage,
findDuplicateInstances, reseatInstance,
} from "lgflow/packages";Errors are { code, message, path } and an empty array means valid — the same
contract validateFlow uses, so a package's errors and its flows' errors
concatenate into one list.
importPackage answers { ok, errors, findings, verdict, at, id, version }.
at is where the package landed, relative to the library root; id and
version are what was imported, so a caller recording the act does not
take that path apart to find out. They are there on a refusal too whenever the
package got far enough to say what it was — the duplicate-version case, which
is the refusal most worth a name — and null when it did not. They are never
read from an unvalidated manifest: a package arrives from outside the
installation, and its id is its own text until validateManifest has had it.