Symphonix Documentation · Core 0.61.0

The solution package format

What a solution package may contain, and the four gates into the library.

This is the engineering reference, rendered from the repository

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 projectsolution package
what it issomewhere somebody workssomething somebody ships
says what it is in.lgflow/project.json — a name, a description, who has worked in itsolution.json — a schema version, an author, what it needs
is it intact?nobody asks; it is a working folderintegrity.json and signature.json, and four gates on the way in
what is in itflows, generated code, node_modules/, a .env — whatever a running agent needsflows, tests, docs, assets, and never code
how it travelscopied, cloned, or deployed toimported, through the gates
who names itthe person working in itthe 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.png

Nothing 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.

pathwhat it is
solution.jsonthe manifest. Required
integrity.jsondigests and inventory. Required
signature.jsona detached signature
README.mdthe package's own front page
docs/<name>.mddocumentation
agents/<id>/flow.jsonone agent's flow
tests/<name>.test.jsonan LGFlow test file
evals/<name>.eval.jsonan LGFlow eval file
assets/<name>.{png,jpg,jpeg,webp}images. Raster only

Refused, each for a reason:

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
idlowercase letters, digits and hyphens. With version, identifies the package
versionan exact semantic version. Not a range: a package version is a thing, not a constraint
kind"solution"
publishera label, and never evidence. See Trust
compatibility.featuresnamed capabilities. See below
agents[]id, flow (which must be agents/<id>/flow.json), primary. Exactly one primary
dependenciesdeclared, never resolved or installed
configurationtyped 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:

  1. the package schema version is one it understands;
  2. every name in features is one it has — asked as explicit membership of FEATURE_VERSIONS, and then whether each flow's own declared version is high enough for it;
  3. every flow validates, through the installed validateFlow(). Not a copy of it.

dependencies#

Declared and reported. Never resolved, installed or connected.

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.

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 kindneeds schemaVersion
mcp1.0
env1.0
packages1.0
database1.1
knowledge1.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 fieldneeds schemaVersion
mcp[].toolsChosenAtInstall1.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" }
]

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:

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.AccountsReceivable

It 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:

kindwhat else moves
mcpthe services.<name> mentions in the flow's prose. Nothing in a node — a node refers to a tool, never to the server
databaseeach database node's from
knowledgeeach 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_MISSINGan alias nobody mapped
MAPPING_IS_AN_ADDRESSa 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_NAMEcrm-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_REGISTEREDthis 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_COLLIDEStwo 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_KINDa mapping that is not keyed by mcp, database or knowledge — usually the old flat shape
MAPPING_UNKNOWN_ALIASa mapping for an alias this package does not declare under that kind
REQUIRED_TOOL_NOT_DECLAREDthe 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:

  1. re-verify the package — admission was in the past;
  2. check the values against the typed parameters;
  3. check the dependency mapping;
  4. 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;
  5. refuse a destination that already has something in it;
  6. write into a staging directory, then one rename;
  7. 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.

situationwhat happens
no provenancecompletely ordinary — that is most flows. known: false, why: "NO_PROVENANCE", and no problem reported
corrupt provenancereported (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 versionread anyway, with PROVENANCE_OTHER_VERSION. Discarding it would lose the only record of where the project came from
a direct edit on diskexpected — 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 projectprovenance 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 instanceIddetectable 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 deletedthe 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 updatecompareWithPackage() 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 file

One 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 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 authority

The 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#

1paths and entries — the allowlist, lstat before read, ceilings, required files, magic bytes
2digests and the exhaustive inventory, both directions
3the signature, the trust store, the validity window, revocation
4the 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:

  1. instantiatePlan() returns the verbatim description of 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.
  2. Generation is a separate step a person starts, and gate 1 of verify.js already refuses a unit importing anything but node: built-ins.
  3. 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.