feat: suport provides field for intents

This commit is contained in:
Khushboo Mehta
2026-08-27 10:15:23 +02:00
parent 01b1f33789
commit cde1984e8d
9 changed files with 260 additions and 21 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ run "$LGX" manifest greeter.lgx | tee m1.txt
grep -q "greeter" m1.txt
grep -q "0.0.1" m1.txt
grep -q "Manifest ver.:" m1.txt
grep -q "0.4.0" m1.txt
grep -q "0.5.0" m1.txt
grep -q "Variants: (none)" m1.txt
grep -q "Signed: no" m1.txt
+49 -7
View File
@@ -55,11 +55,11 @@ package.lgx (tar.gz)
### Manifest Schema
The current manifest schema is `0.4.0`. It is a UTF-8 encoded JSON file with the following required fields:
The current manifest schema is `0.5.0`. It is a UTF-8 encoded JSON file with the following required fields:
```json
{
"manifestVersion": "0.4.0",
"manifestVersion": "0.5.0",
"name": "package-name",
"version": "1.2.3",
"description": "Package description",
@@ -75,7 +75,10 @@ The current manifest schema is `0.4.0`. It is a UTF-8 encoded JSON file with the
"main": {
"linux-amd64": "path/to/main.so",
"darwin-arm64": "path/to/main.dylib"
}
},
"provides": [
{"intent": "chat.group.open"}
]
}
```
@@ -94,8 +97,9 @@ The current manifest schema is `0.4.0`. It is a UTF-8 encoded JSON file with the
| `dependencies` | array | List of dependency entries — see *Dependency entries* below | Runtime needs |
| `main` | object | Map of variant name → relative path to entry point (e.g ) `"linux-amd64": "path/to/main.so"` means `linux-amd64/path/to/main.so` | Entry point resolution |
| `display_name` | string | *Optional.* Human-readable label shown by UI consumers (Package Manager, App Manager) and CLI tools (`lm metadata`, `lgx manifest`). Falls back to `name` when absent. | Display/branding |
| `provides` | array | *Optional.* Intents this package can service — see *Provided intents* below. Absent ⇒ the package services none. | Capability discovery |
All fields except `display_name` are required to ensure consistent metadata for hosts/registries and applications.
All fields except `display_name` and `provides` are required to ensure consistent metadata for hosts/registries and applications.
#### Dependency entries
@@ -109,9 +113,47 @@ Each element of the `dependencies` array is one of:
`lgx verify` syntactically validates that `version` parses as a semver range and that `signer` matches the `did:jwk:` shape. Semantic matching (does the constraint resolve to a real candidate?) is the responsibility of the resolver in `logos-package-downloader`.
#### Provided intents
At `manifestVersion` `0.5.0`+, `provides` lists the **app-to-app intents** the package
can service, e.g. `chat.group.open`. Each element is one of:
- **Object** with `intent` (string, required) — the intent name.
- **Plain string**, accepted on read and equivalent to `{"intent": <string>}`.
Entries are **normalized to the object form on write**, so a reader never sees two
shapes. Entries that are neither a string nor an object carrying a string `intent`,
and entries whose `intent` is empty, are skipped rather than failing the parse. The
array is copied from the reference package by `lgx merge`, so a multi-variant package
keeps the capability claim of the variants it was built from.
**Names only.** The author's `metadata.json` may also describe each intent's payload
shape (`provides[].params`); none of that is carried here. The manifest copy exists to
answer one question, asked *before* a package is installed: "which installable package
provides X?" — which the name alone answers. The shell enforces the payload shape
against the installed `metadata.json`, which stays the source of truth for it.
**Why the signed manifest rather than only `metadata.json`.** `metadata.json` is
unsigned and only readable once the package is on disk. Putting the claim in
`manifest.json` covers it by `manifest.sig`, so a package cannot claim a capability it
was not published with, and makes it legible to a catalog or registry that has the
manifest but has never unpacked — let alone installed — the package.
**Declaration, not authorization.** `provides` says what a package *can* service. It
grants nothing: resolution among candidates, user consent and dispatch are the shell's
(see `logos-basecamp`'s `IntentBroker`), and `lgx` neither validates intent names
against a registry nor checks that the payload implements them.
#### Schema version compatibility
Tooling reads both `manifestVersion: "0.2.x"` and `manifestVersion: "0.3.x"`. Packages produced by `lgx create` use `0.3.0`. A 0.2.0 manifest with plain-string dependencies round-trips unchanged through tooling — strings are emitted as strings, object-form entries are emitted as objects. Bumping the major version (1.x.x) is reserved for future breaking changes.
Tooling reads `manifestVersion` `0.2.x` through `0.5.x`; packages produced by `lgx create`
use `0.5.0`. Every field added across those versions is **optional**, so compatibility runs
both ways: an older client reading a newer manifest ignores what it does not recognize
rather than failing, which is why the check is on the major version alone. A 0.2.0 manifest
with plain-string dependencies round-trips unchanged through tooling — strings are emitted
as strings, object-form entries are emitted as objects. Two contracts are gated on the minor
version rather than applied retroactively: the *icon contract* (`0.4.0`+) and `provides`
(`0.5.0`+). Bumping the major version (1.x.x) is reserved for future breaking changes.
### Icon Contract
@@ -152,8 +194,8 @@ is asserted to be complete.
**Backward compatibility.** Manifests below `0.4.0` are **exempt**. `icon: ""`
was legal at `0.3.0` (it is what `lgx create` defaulted to), so applying the
rule unconditionally would make every already-published package fail
verification and become uninstallable. Tooling reads `0.2.x`, `0.3.x` and
`0.4.x`; only `0.4.0`+ carries the icon contract.
verification and become uninstallable. Tooling reads `0.2.x` through `0.5.x`;
only `0.4.0`+ carries the icon contract.
### `ui_qml` Contract
+2 -2
View File
@@ -104,7 +104,7 @@ sections:
- title: "Read the fresh manifest"
text: |
`lgx manifest` prints the embedded `manifest.json` in human-readable form. A
freshly created package starts at version `0.0.1`, manifest schema `0.4.0`, with
freshly created package starts at version `0.0.1`, manifest schema `0.5.0`, with
no type, no variants, and no signature yet.
run: "./lgx/bin/lgx manifest greeter.lgx"
code_block: "lgx manifest greeter.lgx"
@@ -112,7 +112,7 @@ sections:
- "greeter"
- "0.0.1"
- "Manifest ver.:"
- "0.4.0"
- "0.5.0"
- "Variants: (none)"
- "Signed: no"
+2 -2
View File
@@ -57,7 +57,7 @@ link the result as `./lgx`, so the binary lands at `./lgx/bin/lgx`.
```bash
# From inside the clone this is simply: nix build '.#lgx' -o lgx
nix build 'github:logos-co/logos-package/43378dab2ffc569a28f144d3f62a0f089c3580df#lgx' -o lgx
nix build 'github:logos-co/logos-package/0a1311f809887715b4b6a21820c93591314fb573#lgx' -o lgx
```
The `-o lgx` flag names the result symlink, so the executable is at `./lgx/bin/lgx`.
@@ -108,7 +108,7 @@ sensible defaults plus an empty `variants/` directory — to `<name>.lgx`.
### 3.2 Read the fresh manifest
`lgx manifest` prints the embedded `manifest.json` in human-readable form. A
freshly created package starts at version `0.0.1`, manifest schema `0.4.0`, with
freshly created package starts at version `0.0.1`, manifest schema `0.5.0`, with
no type, no variants, and no signature yet.
```bash
+1 -1
View File
@@ -55,7 +55,7 @@ detail; here they are just setup.)
```bash
# From inside the clone this is simply: nix build '.#lgx' -o lgx
nix build 'github:logos-co/logos-package/43378dab2ffc569a28f144d3f62a0f089c3580df#lgx' -o lgx
nix build 'github:logos-co/logos-package/0a1311f809887715b4b6a21820c93591314fb573#lgx' -o lgx
```
### 1.2 Create and populate greeter.lgx
+1
View File
@@ -142,6 +142,7 @@ int MergeCommand::execute(const std::vector<std::string>& args) {
mergedManifest.icon = refManifest.icon;
mergedManifest.view = refManifest.view;
mergedManifest.dependencies = refManifest.dependencies;
mergedManifest.provides = refManifest.provides;
// Lift the root-level icon from the reference package. At
// manifestVersion 0.4.0+ the icon is variant-independent and lives once
+41 -3
View File
@@ -178,6 +178,28 @@ std::optional<Manifest> Manifest::fromJson(const std::string& jsonStr) {
m.view = j["view"].get<std::string>();
}
// "provides" — optional. Accepts both the object form used in the
// author's metadata.json ([{"intent": "a.b"}]) and a bare string array,
if (j.contains("provides")) {
if (!j["provides"].is_array()) {
lastError_ = "Invalid 'provides' field (must be an array)";
return std::nullopt;
}
for (const auto& entry : j["provides"]) {
ProvidedIntent p;
if (entry.is_string()) {
p.intent = entry.get<std::string>();
} else if (entry.is_object() && entry.contains("intent")
&& entry["intent"].is_string()) {
p.intent = entry["intent"].get<std::string>();
} else {
continue;
}
if (!p.intent.empty()) m.provides.push_back(p);
}
}
// "display_name" — optional human-readable label.
if (j.contains("display_name")) {
if (!j["display_name"].is_string()) {
@@ -246,6 +268,19 @@ std::string Manifest::toJson() const {
if (!displayName.empty()) {
j["display_name"] = displayName;
}
// Objects rather than bare strings, even though `intent` is the only field.
// One shape on the wire, and adding a field later costs no shape change and
// no manifest version bump. A bare string on the way IN is normalised up to
// this form, so readers never see two shapes.
if (!provides.empty()) {
json providesArr = json::array();
for (const auto& p : provides) {
json entry = json::object();
entry["intent"] = p.intent;
providesArr.push_back(std::move(entry));
}
j["provides"] = std::move(providesArr);
}
// Serialize with 2-space indent, sorted keys
return j.dump(2);
@@ -413,9 +448,12 @@ bool Manifest::isVersionSupported(const std::string& version) {
// Currently only major version 0 is supported. Within 0.x we accept
// 0.2.* (legacy plain-string dependencies), 0.3.* (richer dependencies
// with optional version range + signer DID) and 0.4.* (root-level
// assets/ slot); see the Dependency parsing in fromJson() for the
// compatibility shim and requiresIconContract() for the icon gate.
// with optional version range + signer DID), 0.4.* (root-level assets/
// slot) and 0.5.* (`provides`); see the Dependency parsing in fromJson()
// for the compatibility shim and requiresIconContract() for the icon gate.
// Every addition so far has been an OPTIONAL field, so an older client
// reading a newer manifest ignores what it does not know rather than
// failing — which is why this stays a major-version check.
return major == "0";
}
+24 -5
View File
@@ -54,13 +54,25 @@ struct Dependency {
/**
* Manifest represents the manifest.json file in an LGX package.
*/
// An intent a package can service.
//
// NAME ONLY. The author's metadata.json may also describe the payload shape
// (`provides[].params`), and the shell enforces that against the INSTALLED
// metadata.json — but none of it is carried here. The manifest copy exists for
// one question, asked before a package is installed: "which installable package
// provides X?" That is answered by the name alone.
struct ProvidedIntent {
std::string intent;
};
class Manifest {
public:
// Current manifest version. Bumped to 0.4.0 for the root-level assets/
// slot: `icon` now points at a variant-independent asset (assets/icon.png)
// that is exactly 256x256 PNG, so hosts can display it without unpacking
// a platform build. 0.2.x and 0.3.x manifests are still readable.
static constexpr const char* CURRENT_VERSION = "0.4.0";
// Current manifest version. Bumped to 0.5.0 for `provides`: the intents a
// package can service. It lives here, in the SIGNED manifest, rather than
// only in the unsigned metadata.json on disk, so the capability claim is
// covered by the package signature and is legible to a catalog before the
// package is installed. 0.2.x-0.4.x manifests are still readable.
static constexpr const char* CURRENT_VERSION = "0.5.0";
// Canonical in-package icon location for 0.4.0+. The author's
// metadata.json path stays free-form; the bundler normalises to this.
@@ -110,6 +122,13 @@ public:
// human-readable label; consumers fall back to `name` when unset.
std::string displayName;
// Intents this package can service, e.g. "chat.group.open". Names only.
// Copied from the author's metadata.json at bundle time. Carried here so a
// CATALOG can answer "which installable package provides X?" without the
// package being installed — the registry inside a running shell reads the
// installed metadata.json, but nothing outside can.
std::vector<ProvidedIntent> provides;
// Main mapping: variant -> relative path to entry point
std::map<std::string, std::string> main;
+139
View File
@@ -243,6 +243,145 @@ TEST(ManifestTest, ToJson_RoundtripPreservesDisplayName) {
EXPECT_EQ(parsed->displayName, "Friendly Label");
}
TEST(ManifestTest, Provides_AcceptsBothAuthorAndWireForms) {
// The author's metadata.json uses objects (because `uses` needs room for
// cardinality); a hand-written manifest is likely to use bare strings.
// Both parse, and anything without an "intent" string is skipped rather
// than failing the package.
std::string json = R"({
"manifestVersion": "0.5.0",
"name": "chat_ui",
"version": "1.0.0",
"description": "",
"author": "",
"type": "ui_qml",
"category": "",
"icon": "",
"view": "Main.qml",
"dependencies": [],
"provides": [{"intent": "chat.group.open"}, "wallet.sign", {"noIntentKey": 1}]
})";
auto m = Manifest::fromJson(json);
ASSERT_TRUE(m.has_value());
ASSERT_EQ(m->provides.size(), 2u);
EXPECT_EQ(m->provides[0].intent, "chat.group.open");
EXPECT_EQ(m->provides[1].intent, "wallet.sign");
}
TEST(ManifestTest, Provides_EmitsTheObjectFormOnWrite) {
std::string json = R"({
"manifestVersion": "0.5.0",
"name": "chat_ui",
"version": "1.0.0",
"description": "",
"author": "",
"type": "ui_qml",
"category": "",
"icon": "",
"view": "Main.qml",
"dependencies": [],
"provides": [{"intent": "chat.group.open"}]
})";
auto m = Manifest::fromJson(json);
ASSERT_TRUE(m.has_value());
// Objects on the wire, so the parameter shape survives. One shape, not two:
// a provider with no params is still emitted as an object.
const std::string out = m->toJson();
EXPECT_NE(out.find("\"chat.group.open\""), std::string::npos);
EXPECT_NE(out.find("intent"), std::string::npos);
auto round = Manifest::fromJson(out);
ASSERT_TRUE(round.has_value());
ASSERT_EQ(round->provides.size(), m->provides.size());
EXPECT_EQ(round->provides[0].intent, m->provides[0].intent);
}
TEST(ManifestTest, Provides_SurvivesAFieldByFieldCopy) {
// merge_command copies the reference manifest field by field rather than
// assigning the whole struct, so every new field has to be added there by
// hand. `provides` was missed on the first pass: merging per-platform
// packages into a multi-variant one silently un-declared its intents.
//
// This pins the property that matters — capabilities belong to the package,
// not to a platform build, so they must survive the merge.
std::string json = R"({
"manifestVersion": "0.5.0",
"name": "chat_ui",
"version": "1.0.0",
"description": "",
"author": "",
"type": "ui_qml",
"category": "",
"icon": "",
"view": "Main.qml",
"dependencies": [],
"provides": ["chat.group.open"]
})";
auto ref = Manifest::fromJson(json);
ASSERT_TRUE(ref.has_value());
Manifest merged;
merged.manifestVersion = ref->manifestVersion;
merged.name = ref->name;
merged.version = ref->version;
merged.type = ref->type;
merged.view = ref->view;
merged.dependencies = ref->dependencies;
merged.provides = ref->provides;
ASSERT_EQ(merged.provides.size(), ref->provides.size());
EXPECT_EQ(merged.provides[0].intent, ref->provides[0].intent);
EXPECT_NE(merged.toJson().find("chat.group.open"), std::string::npos);
}
TEST(ManifestTest, Provides_KeepsOnlyTheNameNotTheParamShape) {
// The author's metadata.json may describe an intent's payload; the manifest
// deliberately does not carry that description. The shell enforces params
// against the INSTALLED metadata.json, and a second copy here would be a
// bundle-time snapshot that nothing reads and that can drift from the
// original. The catalog question this copy exists to answer — "which
// installable package provides X?" — needs the name and nothing else.
std::string json = R"({
"manifestVersion": "0.5.0",
"name": "wallet_ui",
"version": "1.0.0",
"description": "",
"author": "",
"type": "ui_qml",
"category": "",
"icon": "",
"view": "Main.qml",
"dependencies": [],
"provides": [{
"intent": "wallet.send",
"params": [{"name": "to", "type": "string", "required": true}]
}]
})";
auto m = Manifest::fromJson(json);
ASSERT_TRUE(m.has_value());
ASSERT_EQ(m->provides.size(), 1u);
EXPECT_EQ(m->provides[0].intent, "wallet.send");
// Accepted on the way in, dropped on the way out.
const std::string out = m->toJson();
EXPECT_NE(out.find("wallet.send"), std::string::npos);
EXPECT_EQ(out.find("\"params\""), std::string::npos);
}
TEST(ManifestTest, ToJson_OmitsProvidesWhenUnset) {
auto manifest = Manifest::fromJson(VALID_MANIFEST_JSON);
ASSERT_TRUE(manifest.has_value());
ASSERT_TRUE(manifest->provides.empty());
// Older packages must round-trip byte-identically.
EXPECT_EQ(manifest->toJson().find("provides"), std::string::npos);
}
TEST(ManifestTest, ToJson_OmitsDisplayNameWhenUnset) {
auto manifest = Manifest::fromJson(VALID_MANIFEST_JSON);
ASSERT_TRUE(manifest.has_value());