From b6cd7cfde2e7427642f1da10829d4329dc4a8587 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 06:53:21 +0000 Subject: [PATCH] feat: add OpenFleet membership and CoinPay rental contracts --- .../openfleet-discovery.contract.test.ts | 27 ++ apps/logicsrc-web/src/lib/specs.ts | 1 + docs/data-model.md | 1 + docs/openfleet.md | 158 +++++++++ docs/openswarm.md | 1 + .../schemas/fixtures/openfleet/mixed.json | 86 +++++ packages/schemas/package.json | 4 +- .../schemas/logicsrc-openfleet.schema.json | 323 ++++++++++++++++++ packages/sdk/src/index.ts | 6 + packages/sdk/src/openfleet.test.ts | 35 ++ packages/sdk/src/openfleet.ts | 71 ++++ packages/validators/package.json | 2 +- packages/validators/src/index.ts | 5 + packages/validators/src/openfleet.test.ts | 141 ++++++++ packages/validators/src/openfleet.ts | 72 ++++ packages/validators/src/schemas.ts | 2 + 16 files changed, 933 insertions(+), 2 deletions(-) create mode 100644 apps/logicsrc-web/contract/openfleet-discovery.contract.test.ts create mode 100644 docs/openfleet.md create mode 100644 packages/schemas/fixtures/openfleet/mixed.json create mode 100644 packages/schemas/schemas/logicsrc-openfleet.schema.json create mode 100644 packages/sdk/src/openfleet.test.ts create mode 100644 packages/sdk/src/openfleet.ts create mode 100644 packages/validators/src/openfleet.test.ts create mode 100644 packages/validators/src/openfleet.ts diff --git a/apps/logicsrc-web/contract/openfleet-discovery.contract.test.ts b/apps/logicsrc-web/contract/openfleet-discovery.contract.test.ts new file mode 100644 index 0000000..72d2e53 --- /dev/null +++ b/apps/logicsrc-web/contract/openfleet-discovery.contract.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { familyOfSpec } from "../src/lib/specs"; +import { listDocs, readDoc } from "../src/lib/docs"; +import { GET as llms } from "../src/app/llms.txt/route"; +import { GET as llmsFull } from "../src/app/llms-full.txt/route"; +import sitemap from "../src/app/sitemap"; + +// Spec discovery must work even when the optional blog database is offline. +vi.mock("../src/lib/supabase", () => ({ publicClient: () => { throw new Error("offline"); } })); + +describe("OpenFleet public discovery", () => { + it("serves the specification through the process family and docs index", () => { + expect(familyOfSpec("openfleet")?.slug).toBe("process"); + expect(listDocs()).toContainEqual(expect.objectContaining({ slug: "openfleet", title: "OpenFleet" })); + expect(readDoc("openfleet")).toContain('"provider": "coinpay"'); + }); + + it("includes a reachable docs URL and the full contract in the LLM feeds", async () => { + expect(await llms().text()).toMatch(/\[OpenFleet\]\(https:\/\/[^)]+\/docs\/openfleet\)/); + expect(await llmsFull().text()).toContain(readDoc("openfleet")!.trim()); + }); + + it("includes the docs route in the sitemap without a blog connection", async () => { + const entries = await sitemap(); + expect(entries.some((entry) => new URL(entry.url).pathname === "/docs/openfleet")).toBe(true); + }); +}); diff --git a/apps/logicsrc-web/src/lib/specs.ts b/apps/logicsrc-web/src/lib/specs.ts index 1bebc0f..a849279 100644 --- a/apps/logicsrc-web/src/lib/specs.ts +++ b/apps/logicsrc-web/src/lib/specs.ts @@ -103,6 +103,7 @@ export const FAMILIES: Family[] = [ specs: [ s("asdlc", "ASDLC", "The Agentic Software Development Lifecycle: nine phases, four conformance levels and the ratchet rule"), s("openprd", "OpenPRD", "A product requirement document an agent can execute and a person can read"), + s("openfleet", "OpenFleet", "Fleets of OpenAgent and OpenSwarm members, with metadata and rental rates through CoinPay", { landing: undefined, status: "draft" }), s("openswarm", "OpenSwarm", "Settlement and proof of work done under a peer-to-peer swarm"), s("openstream", "OpenStream", "A lossless byte-stream relay envelope, with benchmark reports per release", { landing: undefined }), s("openontology", "OpenOntology", "Five nouns for a shared ontology, with governance and interoperability notes"), diff --git a/docs/data-model.md b/docs/data-model.md index 4e3b7a3..ad7d910 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -51,6 +51,7 @@ Important relationships: - Task can have many submissions. - Agent can have many runs. - Agent run belongs to one task. +- [OpenFleet](/docs/openfleet) groups OpenAgent profile references and/or OpenSwarm file-key references, with fleet metadata and CoinPay rental offers scoped to the fleet or named members. - Reputation events belong to DIDs. - API keys belong to users, agents, or service accounts. - Permissions are scoped to resources. diff --git a/docs/openfleet.md b/docs/openfleet.md new file mode 100644 index 0000000..0ab785b --- /dev/null +++ b/docs/openfleet.md @@ -0,0 +1,158 @@ +# OpenFleet + +OpenFleet describes a fleet containing OpenAgent members, OpenSwarm members, or both, with fleet metadata and explicit rental offers paid through CoinPay. A fleet can publish an offer for the entire group or for selected members. Membership alone does not put a member up for rent. + +Status: 0.1 draft. The JSON Schema, offline validator, fixtures and SDK constructor are implemented in this repository. Member resolution, authorization, scheduling and payment execution belong to the application that consumes the descriptor. + +## What a member is + +Each member has a `kind`, stable `id` and HTTPS `url`. The pair `(kind, id)` is its identity within the fleet; changing its URL or metadata does not create a second member. A fleet MUST contain at least one member, and MUST NOT repeat that identity. Agent-only, swarm-only and mixed fleets are equally valid. A member may belong to more than one fleet. + +| Kind | Identity | Document at `url` | +| --- | --- | --- | +| `openagent` | The `did` of a LogicSRC agent profile, such as `analyst.coinpay` | A document conforming to the existing [`logicsrc.agent` schema](https://github.com/profullstack/logicsrc/blob/master/packages/schemas/schemas/logicsrc-agent.schema.json); its `did` MUST equal the member `id` | +| `openswarm` | `ed25519:` followed by the 64 lowercase hex characters of the swarm's file public key | The signed `ipfile.manifest` for that file, served by its publisher or an HTTP gateway; its `file` key MUST equal the member `id` | + +**OpenAgent binding:** this draft uses the existing LogicSRC agent profile as its OpenAgent representation. It does not introduce a second profile format. Agent identities, fleet `owner_did` and rental `payee_did` use the existing LogicSRC DID syntax (`name.coinpay`, for example), rather than a new identity namespace. + +**OpenSwarm binding:** [OpenSwarm](/docs/openswarm) retains its meaning as the paid, encrypted peer-to-peer distribution family. A member is an actual `ipfile` swarm identified by its stable file key, not a content-version hash, publisher key, hub, or AgentSwarm orchestration session. The manifest may change under that key. AgentSwarm's runtime can consume a fleet as application input, but a runtime session is not implicitly an `openswarm` member. The `url` is an HTTP view of the existing manifest, not a new OpenSwarm endpoint requirement. + +Fleet membership is a grouping reference. It neither copies the member document nor changes the member's operator, capabilities, permissions, pricing or settlement rules. Fleets cannot contain other fleets in version 0.1. Readers do not recursively expand swarm participants into fleet members. + +## Publication and discovery + +Serve a descriptor as `application/json` over HTTPS, normally at `/.well-known/openfleet.json`. `id` is the canonical HTTPS URL of this fleet descriptor; the well-known URL may serve the same document. An operator with several fleets serves each at its own canonical URL and links them from its site or directory listing. Each descriptor still represents one fleet. + +Directories fetch the operator's document, retain its canonical ID and source URL, and display `updated_at` and any offer validity window. The specification is listed in LogicSRC's Agents and process family, documentation index, sitemap and LLM discovery files. + +## Example + +The URLs and identities below are illustrative, not a live checkout or a claim of control over a running swarm. + +```json +{ + "type": "logicsrc.openfleet", + "version": "0.1", + "id": "https://example.com/fleets/research", + "name": "Research fleet", + "owner_did": "operator.coinpay", + "description": "An analyst and an encrypted reference-data swarm", + "availability": "available", + "metadata": { "region": "eu-west" }, + "members": [ + { + "kind": "openagent", + "id": "analyst.coinpay", + "url": "https://example.com/agents/analyst.json", + "role": "analysis" + }, + { + "kind": "openswarm", + "id": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "url": "https://example.com/swarms/references/manifest.json", + "role": "reference-data" + } + ], + "rentals": [ + { + "id": "hourly", + "scope": { "kind": "fleet" }, + "rate": { "amount": "25.000000", "currency": "USD", "unit": "hour" }, + "minimum_units": 1, + "terms_url": "https://example.com/rental-terms", + "payment": { + "provider": "coinpay", + "payee_did": "operator.coinpay", + "checkout_url": "https://example.com/rentals/research" + } + } + ] +} +``` + +## Fleet and member metadata + +| Field | Rule | +| --- | --- | +| `type`, `version` | Required constants `logicsrc.openfleet` and `0.1` | +| `id`, `name`, `owner_did` | Required canonical descriptor URL, nonempty display name and responsible operator's DID | +| `members` | Required nonempty array of typed member references | +| `description` | Optional human-readable description | +| `updated_at` | Optional RFC 3339 instant when the descriptor was last revised | +| `availability` | Optional `available`, `busy`, `offline` or `unknown`; absent means unknown, and an available listing is not a reservation | +| `tags`, `capabilities` | Optional arrays of distinct nonempty strings; fleet-level claims that do not override a member's permissions | +| `metadata` | Optional JSON object for additional fleet data, such as region, support information or hardware inventory | +| `rentals` | Optional array of explicit rental offers; absent or empty means no rental offer is advertised | + +Members may also carry `name`, `role` and a JSON-object `metadata` field. Unknown fields outside `metadata` are rejected in 0.1 to catch misspelled contract fields. Metadata MUST contain only information intended for publication, never account credentials or payment secrets. Readers preserve metadata, but it cannot override normative fields. + +## Rental offers + +Each rental requires a unique fleet-local `id`, a `scope`, a `rate` and a `payment` block. `name`, `terms_url`, `metadata`, unit bounds and validity times are optional. + +| Field | Meaning | +| --- | --- | +| `scope: { "kind": "fleet" }` | One rate for the whole fleet as listed in the accepted quote; never multiplied by the member count | +| `scope: { "kind": "members", "members": [...] }` | One rate for exactly those `(kind, id)` references, including either kind or both; the list must be nonempty, contain no duplicates and reference existing fleet members | +| `rate.amount` | Non-negative decimal **string** with exactly six fractional digits, such as `"0.002500"`; scientific notation, negative amounts and JSON numbers are invalid | +| `rate.currency` | `USD` in version 0.1, following OpenSwarm's amount convention | +| `rate.unit` | `hour` (3,600 seconds), `day` (86,400 seconds), `month` (30 days), or `task` (one agreed deliverable) | +| `minimum_units`, `maximum_units` | Positive safe integers; minimum defaults to 1, an absent maximum is unstated, and maximum cannot be below minimum | +| `valid_from`, `valid_until` | Optional RFC 3339 bounds on when an offer may be accepted; start is inclusive, end is exclusive, and end must be later than start when both are present | +| `terms_url` | Optional HTTPS URL explaining deliverables, availability, billing, cancellation and any separate usage charges | + +An individual-member offer therefore uses a scope such as: + +```json +{ + "kind": "members", + "members": [{ "kind": "openagent", "id": "analyst.coinpay" }] +} +``` + +The whole-fleet and member offers are alternatives, not charges automatically added together. The descriptor makes no implicit per-member rate inheritance. Removing a member requires updating or removing offers that reference it. If membership or terms change, the consumer MUST obtain a new quote before acceptance; an existing accepted rental keeps its agreed member set and terms. + +Amounts follow [OpenSwarm core's six-decimal USD convention](https://github.com/profullstack/logicsrc/blob/master/docs/openswarm/spec.md). Consumers calculate with integer millionths of a dollar or a decimal library, never JavaScript `Number`. For example, `"0.002500"` becomes `2500n` millionths; 3 agreed billing units cost `7500n`, or `"0.007500"`. `"0.000000"` explicitly states a zero rental rate. Missing rentals never imply free use. + +Version 0.1 quotes whole billing units. For timed rentals the accepted quote MUST state a whole number of units and the covered period; partial use does not silently introduce a rounding rule. A task offer's accepted quote MUST specify its deliverable. Taxes, collateral, cancellation, additional usage and currency conversion are agreed at checkout; clients MUST NOT infer them from this rate. The offer's validity window concerns acceptance, not the period of service. Validation checks that a window is ordered, not that an offer is currently available. + +## CoinPay settlement + +Every rental advertises `payment.provider: "coinpay"`, a `payee_did` and an HTTPS `checkout_url` published by the merchant. The payee can differ from the fleet owner when the owner authorizes that settlement recipient. The checkout URL is an entry point for agreeing to this offer and obtaining a CoinPay-backed checkout or escrow; OpenFleet specifies no new CoinPay API path, rail, wallet address format or payment-proof format. + +The consuming application confirms the fleet ID, rental ID, current member set, exact amount, units, payee and terms with the merchant before creating any payment. A checkout response, signed agreement or application record binds those values; the application verifies payment using its configured CoinPay integration. Fetching or validating a descriptor never initiates a payment, and following its URL does not itself prove that CoinPay backs a merchant's claim. + +For an OpenSwarm member, the existing `ippay` grants, passes, vouchers and proof rules still govern actual access and delivery. A fleet rental does not replace them, mint a key grant or grant access to an agent's connected accounts. Terms must state whether underlying swarm usage is included or charged separately. Fleet-level payment does not authorize automatic fan-out payouts to members. + +## Validation and SDK + +The schema is exported as `@logicsrc/schemas/openfleet`. Its agent identity definition references `@logicsrc/schemas/agent`, so standalone JSON Schema consumers must register that schema as well. `@logicsrc/validators` registers both automatically and additionally checks member and rental uniqueness, dangling rental references, unit bounds and validity ordering. + +```ts +import { createOpenFleet } from "@logicsrc/sdk"; +import { validate } from "@logicsrc/validators"; + +const fleet = createOpenFleet({ + id: "https://example.com/fleets/analyst", + name: "Analyst fleet", + owner_did: "operator.coinpay", + members: [{ + kind: "openagent", + id: "analyst.coinpay", + url: "https://example.com/agents/analyst.json" + }] +}); +const result = validate("openfleet", fleet); +if (!result.ok) throw new Error(JSON.stringify(result.errors)); +``` + +`createOpenFleet` adds the type and version; it is a constructor, not a runtime validator. The SDK exports `OpenFleet`, `OpenFleetMember`, `OpenFleetMemberReference`, `OpenFleetRental` and `OpenFleetRentalScope` types. The validator does not mutate input or fetch URLs, resolve DIDs, verify manifests, establish operator authority or execute payments. Consumers MUST resolve and verify the referenced identities and the operator's authority before scheduling work or accepting a rental. Merely listing somebody else's agent or swarm does not confer authority to rent it. + +Run the supplied mixed-fleet fixture through the CLI after building the validators: + +```sh +npm --workspace @logicsrc/validators run build +node packages/validators/dist/cli.js openfleet packages/schemas/fixtures/openfleet/mixed.json +``` + +The complete [fixture](https://github.com/profullstack/logicsrc/blob/master/packages/schemas/fixtures/openfleet/mixed.json) includes both a fleet-wide hourly offer and an agent-only per-task offer. The [schema](https://github.com/profullstack/logicsrc/blob/master/packages/schemas/schemas/logicsrc-openfleet.schema.json) plus the semantic checks in `@logicsrc/validators` are the executable 0.1 contract. diff --git a/docs/openswarm.md b/docs/openswarm.md index e314125..7828ee2 100644 --- a/docs/openswarm.md +++ b/docs/openswarm.md @@ -43,6 +43,7 @@ a different product. The member protocols keep their `ip` names. Supporting documents: +- [OpenFleet](./openfleet.md): group `ipfile` swarms and OpenAgent profiles under one descriptor, with fleet metadata and CoinPay rental offers; each swarm keeps its existing identity and settlement rules. - c0mpute.com integration and use cases: [`c0mpute.md`](./openswarm/c0mpute.md) - Proposed `ip` CLI: [`cli.md`](./openswarm/cli.md) - Conformance: [`conformance.md`](./openswarm/conformance.md) diff --git a/packages/schemas/fixtures/openfleet/mixed.json b/packages/schemas/fixtures/openfleet/mixed.json new file mode 100644 index 0000000..aa6cce5 --- /dev/null +++ b/packages/schemas/fixtures/openfleet/mixed.json @@ -0,0 +1,86 @@ +{ + "type": "logicsrc.openfleet", + "version": "0.1", + "id": "https://example.com/fleets/research", + "name": "Research and distribution fleet", + "owner_did": "operator.coinpay", + "description": "An analyst agent and an encrypted reference-data swarm, offered together or separately.", + "updated_at": "2026-09-13T12:00:00Z", + "availability": "available", + "tags": [ + "research", + "distribution" + ], + "capabilities": [ + "analysis", + "paid-seeding" + ], + "metadata": { + "region": "eu-west", + "support_url": "https://example.com/support" + }, + "members": [ + { + "kind": "openagent", + "id": "analyst.coinpay", + "url": "https://example.com/agents/analyst.json", + "name": "Analyst", + "role": "analysis", + "metadata": { + "languages": [ + "en" + ] + } + }, + { + "kind": "openswarm", + "id": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "url": "https://example.com/swarms/references/manifest.json", + "name": "Reference corpus", + "role": "reference-data" + } + ], + "rentals": [ + { + "id": "fleet-hourly", + "scope": { + "kind": "fleet" + }, + "rate": { + "amount": "25.000000", + "currency": "USD", + "unit": "hour" + }, + "minimum_units": 1, + "maximum_units": 24, + "terms_url": "https://example.com/rental-terms", + "payment": { + "provider": "coinpay", + "payee_did": "operator.coinpay", + "checkout_url": "https://example.com/rentals/research" + } + }, + { + "id": "analyst-task", + "scope": { + "kind": "members", + "members": [ + { + "kind": "openagent", + "id": "analyst.coinpay" + } + ] + }, + "rate": { + "amount": "5.500000", + "currency": "USD", + "unit": "task" + }, + "payment": { + "provider": "coinpay", + "payee_did": "operator.coinpay", + "checkout_url": "https://example.com/rentals/analyst" + } + } + ] +} diff --git a/packages/schemas/package.json b/packages/schemas/package.json index a1e33d1..f0192b0 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -1,7 +1,7 @@ { "name": "@logicsrc/schemas", "version": "0.1.0", - "description": "LogicSRC JSON schemas for tasks, agents, runs, events, plugins, the AgentAd ad standard, the OpenOntology knowledge contracts, the OpenContext context plane, and the OpenCreds credential vault.", + "description": "LogicSRC JSON schemas for tasks, agents, runs, events, plugins, the AgentAd ad standard, the OpenOntology knowledge contracts, the OpenContext context plane, the OpenCreds credential vault, and OpenFleet membership and rental offers.", "license": "MIT", "type": "module", "repository": { @@ -22,6 +22,7 @@ "ontology", "opencontext", "opencreds", + "openfleet", "openontology", "password-manager", "standards", @@ -64,6 +65,7 @@ "./opencreds-item": "./schemas/logicsrc-opencreds-item.schema.json", "./opencreds-manifest": "./schemas/logicsrc-opencreds-manifest.schema.json", "./opencreds-vault-meta": "./schemas/logicsrc-opencreds-vault-meta.schema.json", + "./openfleet": "./schemas/logicsrc-openfleet.schema.json", "./openontology-action": "./schemas/logicsrc-openontology-action.schema.json", "./openontology-approval": "./schemas/logicsrc-openontology-approval.schema.json", "./openontology-changeset": "./schemas/logicsrc-openontology-changeset.schema.json", diff --git a/packages/schemas/schemas/logicsrc-openfleet.schema.json b/packages/schemas/schemas/logicsrc-openfleet.schema.json new file mode 100644 index 0000000..686ff15 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-openfleet.schema.json @@ -0,0 +1,323 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.logicsrc.com/logicsrc-openfleet.schema.json", + "title": "OpenFleet", + "description": "A fleet of OpenAgent profiles and/or OpenSwarm swarms with explicit CoinPay rental offers.", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "version", + "id", + "name", + "owner_did", + "members" + ], + "properties": { + "type": { + "const": "logicsrc.openfleet" + }, + "version": { + "const": "0.1" + }, + "id": { + "$ref": "#/$defs/httpsUrl" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "description": { + "type": "string" + }, + "owner_did": { + "$ref": "https://schemas.logicsrc.com/logicsrc-agent.schema.json#/$defs/did" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "availability": { + "enum": [ + "available", + "busy", + "offline", + "unknown" + ] + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "capabilities": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "metadata": { + "type": "object" + }, + "members": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/member" + } + }, + "rentals": { + "type": "array", + "items": { + "$ref": "#/$defs/rental" + } + } + }, + "$defs": { + "httpsUrl": { + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "memberReference": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id" + ], + "properties": { + "kind": { + "const": "openagent" + }, + "id": { + "$ref": "https://schemas.logicsrc.com/logicsrc-agent.schema.json#/$defs/did" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id" + ], + "properties": { + "kind": { + "const": "openswarm" + }, + "id": { + "type": "string", + "pattern": "^ed25519:[0-9a-f]{64}$" + } + } + } + ] + }, + "member": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id", + "url" + ], + "properties": { + "kind": { + "const": "openagent" + }, + "id": { + "$ref": "https://schemas.logicsrc.com/logicsrc-agent.schema.json#/$defs/did" + }, + "url": { + "$ref": "#/$defs/httpsUrl" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "minLength": 1 + }, + "metadata": { + "type": "object" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id", + "url" + ], + "properties": { + "kind": { + "const": "openswarm" + }, + "id": { + "type": "string", + "pattern": "^ed25519:[0-9a-f]{64}$" + }, + "url": { + "$ref": "#/$defs/httpsUrl" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "minLength": 1 + }, + "metadata": { + "type": "object" + } + } + } + ] + }, + "scope": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "fleet" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "members" + ], + "properties": { + "kind": { + "const": "members" + }, + "members": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/memberReference" + } + } + } + } + ] + }, + "rental": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "scope", + "rate", + "payment" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "scope": { + "$ref": "#/$defs/scope" + }, + "rate": { + "type": "object", + "additionalProperties": false, + "required": [ + "amount", + "currency", + "unit" + ], + "properties": { + "amount": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{6}$", + "description": "Non-negative USD decimal string with exactly six fractional digits; never a JSON number." + }, + "currency": { + "const": "USD" + }, + "unit": { + "enum": [ + "hour", + "day", + "month", + "task" + ] + } + } + }, + "minimum_units": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maximum_units": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "valid_from": { + "type": "string", + "format": "date-time" + }, + "valid_until": { + "type": "string", + "format": "date-time" + }, + "terms_url": { + "$ref": "#/$defs/httpsUrl" + }, + "payment": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider", + "payee_did", + "checkout_url" + ], + "properties": { + "provider": { + "const": "coinpay" + }, + "payee_did": { + "$ref": "https://schemas.logicsrc.com/logicsrc-agent.schema.json#/$defs/did" + }, + "checkout_url": { + "$ref": "#/$defs/httpsUrl" + } + } + }, + "metadata": { + "type": "object" + } + } + } + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e01d3d1..207dcd7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,3 +1,9 @@ +export { createOpenFleet } from "./openfleet.js"; +export type { + OpenFleet, OpenFleetJsonValue, OpenFleetMember, OpenFleetMemberReference, + OpenFleetRental, OpenFleetRentalScope +} from "./openfleet.js"; + export type LogicSrcId = string; export interface LogicSrcTask { diff --git a/packages/sdk/src/openfleet.test.ts b/packages/sdk/src/openfleet.test.ts new file mode 100644 index 0000000..e40b73e --- /dev/null +++ b/packages/sdk/src/openfleet.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { createOpenFleet, type OpenFleetMember } from "./index.js"; + +const members: OpenFleetMember[] = [ + { kind: "openagent", id: "analyst.coinpay", url: "https://example.com/agent.json" }, + { kind: "openswarm", id: `ed25519:${"a".repeat(64)}`, url: "https://example.com/manifest.json" } +]; + +describe("createOpenFleet", () => { + it.each([members.slice(0, 1), members.slice(1), members])("constructs single-kind and mixed fleets", (...fleetMembers) => { + const fleet = createOpenFleet({ + id: "https://example.com/fleets/research", name: "Research", owner_did: "operator.coinpay", + members: fleetMembers + }); + expect(fleet.type).toBe("logicsrc.openfleet"); + expect(fleet.version).toBe("0.1"); + expect(fleet.members).toEqual(fleetMembers); + expect(fleet.rentals).toBeUndefined(); + }); + + it("keeps rental amounts as decimal strings and preserves metadata", () => { + const input: Parameters[0] = { + id: "https://example.com/fleets/research", name: "Research", owner_did: "operator.coinpay", members, + metadata: { support: { languages: ["en", "fr"] } }, + rentals: [{ + id: "hourly", scope: { kind: "fleet" }, + rate: { amount: "9007199254740993.123456", currency: "USD", unit: "hour" }, + payment: { provider: "coinpay", payee_did: "operator.coinpay", checkout_url: "https://example.com/rent" } + }] + }; + const snapshot = structuredClone(input); + expect(createOpenFleet(input)).toEqual({ ...input, type: "logicsrc.openfleet", version: "0.1" }); + expect(input).toEqual(snapshot); + }); +}); diff --git a/packages/sdk/src/openfleet.ts b/packages/sdk/src/openfleet.ts new file mode 100644 index 0000000..ba3f1bf --- /dev/null +++ b/packages/sdk/src/openfleet.ts @@ -0,0 +1,71 @@ +export type OpenFleetJsonValue = + | null | boolean | number | string + | OpenFleetJsonValue[] | { [key: string]: OpenFleetJsonValue }; + +/** OpenAgent binds to a logicsrc.agent DID; OpenSwarm binds to a file key. */ +export type OpenFleetMemberReference = + | { kind: "openagent"; id: string } + | { kind: "openswarm"; id: `ed25519:${string}` }; + +export type OpenFleetMember = OpenFleetMemberReference & { + /** HTTPS URL of the agent profile or the signed ipfile manifest. */ + url: string; + name?: string; + role?: string; + metadata?: Record; +}; + +export type OpenFleetRentalScope = + | { kind: "fleet" } + | { kind: "members"; members: OpenFleetMemberReference[] }; + +export interface OpenFleetRental { + id: string; + name?: string; + scope: OpenFleetRentalScope; + rate: { + /** Exact non-negative USD decimal string, with six fractional digits. */ + amount: string; + currency: "USD"; + /** Whole billing units; a month is 30 days, a task an agreed deliverable. */ + unit: "hour" | "day" | "month" | "task"; + }; + minimum_units?: number; + maximum_units?: number; + valid_from?: string; + valid_until?: string; + terms_url?: string; + payment: { + provider: "coinpay"; + payee_did: string; + /** Merchant-published HTTPS checkout entry point; no fixed CoinPay API. */ + checkout_url: string; + }; + metadata?: Record; +} + +export interface OpenFleet { + type: "logicsrc.openfleet"; + version: "0.1"; + /** Stable HTTPS fleet identifier, unique at its publisher's origin. */ + id: string; + name: string; + owner_did: string; + description?: string; + updated_at?: string; + availability?: "available" | "busy" | "offline" | "unknown"; + tags?: string[]; + capabilities?: string[]; + metadata?: Record; + members: OpenFleetMember[]; + rentals?: OpenFleetRental[]; +} + +/** + * Construct a descriptor without fetching members or initiating payments. + * Use validate("openfleet", document) from @logicsrc/validators before use: + * TypeScript alone cannot check DID/key formats or rental references. + */ +export function createOpenFleet(input: Omit): OpenFleet { + return { ...input, type: "logicsrc.openfleet", version: "0.1" }; +} diff --git a/packages/validators/package.json b/packages/validators/package.json index e1ca58c..78dcb9f 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "test": "vitest run src", - "validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml && node dist/cli.js repo ../schemas/fixtures/repo.yaml && node dist/cli.js pull-request ../schemas/fixtures/pull-request.yaml && node dist/cli.js openontology-manifest ../schemas/fixtures/openontology/valid/manifest.json && node dist/cli.js openontology-claim ../schemas/fixtures/openontology/valid/claim-relationship.json && node dist/cli.js openontology-changeset ../schemas/fixtures/openontology/valid/changeset.json && node dist/cli.js opencontext-manifest ../schemas/fixtures/opencontext/valid/manifest.json && node dist/cli.js opencontext-object ../schemas/fixtures/opencontext/valid/object-policy.json && node dist/cli.js opencontext-bundle ../schemas/fixtures/opencontext/valid/bundle.json && node dist/cli.js opencontext-decision ../schemas/fixtures/opencontext/valid/decision.json && node dist/cli.js opencontext-role ../schemas/fixtures/opencontext/valid/role.json && node dist/cli.js opencontext-provenance ../schemas/fixtures/opencontext/valid/provenance.json && node dist/cli.js opencontext-diagnostic ../schemas/fixtures/opencontext/valid/diagnostic.json && node dist/cli.js opencontext-audit-event ../schemas/fixtures/opencontext/valid/audit-event.json" + "validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml && node dist/cli.js repo ../schemas/fixtures/repo.yaml && node dist/cli.js pull-request ../schemas/fixtures/pull-request.yaml && node dist/cli.js openontology-manifest ../schemas/fixtures/openontology/valid/manifest.json && node dist/cli.js openontology-claim ../schemas/fixtures/openontology/valid/claim-relationship.json && node dist/cli.js openontology-changeset ../schemas/fixtures/openontology/valid/changeset.json && node dist/cli.js opencontext-manifest ../schemas/fixtures/opencontext/valid/manifest.json && node dist/cli.js opencontext-object ../schemas/fixtures/opencontext/valid/object-policy.json && node dist/cli.js opencontext-bundle ../schemas/fixtures/opencontext/valid/bundle.json && node dist/cli.js opencontext-decision ../schemas/fixtures/opencontext/valid/decision.json && node dist/cli.js opencontext-role ../schemas/fixtures/opencontext/valid/role.json && node dist/cli.js opencontext-provenance ../schemas/fixtures/opencontext/valid/provenance.json && node dist/cli.js opencontext-diagnostic ../schemas/fixtures/opencontext/valid/diagnostic.json && node dist/cli.js opencontext-audit-event ../schemas/fixtures/opencontext/valid/audit-event.json && node dist/cli.js openfleet ../schemas/fixtures/openfleet/mixed.json" }, "dependencies": { "@logicsrc/schemas": "^0.1.0", diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index 76d811f..0d02cc1 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -3,6 +3,7 @@ import * as addFormatsModule from "ajv-formats"; import type { ErrorObject } from "ajv"; import { parse } from "yaml"; import { isSchemaKind, schemas, type SchemaKind } from "./schemas.js"; +import { validateOpenFleetReferences } from "./openfleet.js"; type CompiledSchema = { (data: unknown): boolean; errors?: ErrorObject[] | null }; @@ -61,6 +62,10 @@ export function validate(kind: SchemaKind, data: unknown): ValidationResult { const ok = validateDocument(data); if (ok) { + if (kind === "openfleet") { + const errors = validateOpenFleetReferences(data); + if (errors.length) return { ok: false, kind, errors }; + } return { ok: true, kind, data }; } diff --git a/packages/validators/src/openfleet.test.ts b/packages/validators/src/openfleet.test.ts new file mode 100644 index 0000000..8948290 --- /dev/null +++ b/packages/validators/src/openfleet.test.ts @@ -0,0 +1,141 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { assertSchemaKind, createValidator, schemas, validate } from "./index.js"; + +function fixture() { + return JSON.parse(readFileSync(new URL("../../schemas/fixtures/openfleet/mixed.json", import.meta.url), "utf8")); +} + +function errorAt(data: unknown, keyword: string, path: string) { + const result = validate("openfleet", data); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors).toContainEqual(expect.objectContaining({ keyword, instancePath: path })); +} + +describe("OpenFleet", () => { + it("registers a publicly exported schema and validates the mixed fixture", () => { + expect(assertSchemaKind("openfleet")).toBe("openfleet"); + expect(validate("openfleet", fixture()).ok).toBe(true); + const ajv = createValidator(); + ajv.addSchema(schemas.agent); + expect(ajv.compile(schemas.openfleet)(fixture())).toBe(true); + }); + + it("validates the complete descriptor in the public specification", () => { + const doc = readFileSync(new URL("../../../docs/openfleet.md", import.meta.url), "utf8"); + const example = doc.match(/```json\n([\s\S]*?)\n```/); + expect(example).not.toBeNull(); + expect(validate("openfleet", JSON.parse(example![1])).ok).toBe(true); + }); + + it.each(["openagent", "openswarm"])("accepts a fleet containing only %s members", (kind) => { + const fleet = fixture(); + fleet.members = fleet.members.filter((member: { kind: string }) => member.kind === kind); + // A whole-fleet rate applies to either single-kind fleet without expansion. + fleet.rentals = [fleet.rentals[0]]; + expect(validate("openfleet", fleet).ok).toBe(true); + }); + + it("accepts membership without rental offers and preserves extension metadata", () => { + const fleet = fixture(); + delete fleet.rentals; + fleet.metadata = { custom: { nested: [null, true, 42, "value"] } }; + const snapshot = structuredClone(fleet); + expect(validate("openfleet", fleet).ok).toBe(true); + expect(fleet).toEqual(snapshot); + }); + + it.each([ + ["no members", (f: ReturnType) => { f.members = []; }], + ["unsupported kind", (f: ReturnType) => { f.members[0].kind = "openfleet"; }], + ["agent without identity", (f: ReturnType) => { delete f.members[0].id; }], + ["swarm without identity", (f: ReturnType) => { delete f.members[1].id; }], + ["swarm with an agent DID", (f: ReturnType) => { f.members[1].id = "analyst.coinpay"; }], + ["agent with a swarm key", (f: ReturnType) => { f.members[0].id = f.members[1].id; }], + ["short swarm key", (f: ReturnType) => { f.members[1].id = "ed25519:abcd"; }], + ["non-HTTPS member URL", (f: ReturnType) => { f.members[0].url = "http://example.com/agent"; }], + ["unsupported version", (f: ReturnType) => { f.version = "9.0"; }], + ["unknown contract field", (f: ReturnType) => { f.rental = []; }] + ])("rejects %s", (_, mutate) => { + const fleet = fixture(); + mutate(fleet); + expect(validate("openfleet", fleet).ok).toBe(false); + }); + + it("rejects duplicate member identities even when URL and metadata differ", () => { + const fleet = fixture(); + fleet.members.push({ ...fleet.members[0], url: "https://elsewhere.example/agent", metadata: { alias: true } }); + errorAt(fleet, "uniqueMember", "/members/2"); + }); + + it("rejects duplicate rental IDs", () => { + const fleet = fixture(); + fleet.rentals[1].id = fleet.rentals[0].id; + errorAt(fleet, "uniqueRental", "/rentals/1/id"); + }); + + it("rejects dangling references after a member is removed", () => { + const fleet = fixture(); + fleet.members.shift(); + errorAt(fleet, "memberReference", "/rentals/1/scope/members/0"); + }); + + it("accepts a rental scoped to both kinds without counting a member twice", () => { + const fleet = fixture(); + fleet.rentals[1].scope.members.push({ kind: "openswarm", id: fleet.members[1].id }); + expect(validate("openfleet", fleet).ok).toBe(true); + fleet.rentals[1].scope.members.push({ ...fleet.rentals[1].scope.members[0] }); + errorAt(fleet, "uniqueMember", "/rentals/1/scope/members/2"); + }); + + it.each([25, -1, "-1.000000", "01.000000", "1e3", "NaN", "Infinity", "1.00", "0.0000001"])( + "rejects an inexact or invalid amount %s", (amount) => { + const fleet = fixture(); + fleet.rentals[0].rate.amount = amount; + expect(validate("openfleet", fleet).ok).toBe(false); + } + ); + + it.each(["0.000000", "0.000001", "9007199254740993.123456"])("preserves exact amount %s", (amount) => { + const fleet = fixture(); + fleet.rentals[0].rate.amount = amount; + expect(validate("openfleet", fleet).ok).toBe(true); + expect(fleet.rentals[0].rate.amount).toBe(amount); + }); + + it.each([ + ["non-CoinPay provider", (r: ReturnType) => { r.payment.provider = "other"; }], + ["missing CoinPay metadata", (r: ReturnType) => { delete r.payment; }], + ["missing payee", (r: ReturnType) => { delete r.payment.payee_did; }], + ["missing checkout", (r: ReturnType) => { delete r.payment.checkout_url; }], + ["insecure checkout", (r: ReturnType) => { r.payment.checkout_url = "http://example.com/checkout"; }], + ["unsupported currency", (r: ReturnType) => { r.rate.currency = "EUR"; }], + ["missing unit", (r: ReturnType) => { delete r.rate.unit; }], + ["unsupported unit", (r: ReturnType) => { r.rate.unit = "second"; }], + ["fractional billing units", (r: ReturnType) => { r.minimum_units = 0.5; }], + ["unsafe integer units", (r: ReturnType) => { r.maximum_units = 9007199254740992; }], + ["empty member scope", (r: ReturnType) => { r.scope = { kind: "members", members: [] }; }], + ["ambiguous fleet scope", (r: ReturnType) => { r.scope.members = []; }] + ])("rejects a rental with %s", (_, mutate) => { + const fleet = fixture(); + mutate(fleet.rentals[0]); + expect(validate("openfleet", fleet).ok).toBe(false); + }); + + it("rejects maximum units below the minimum", () => { + const fleet = fixture(); + fleet.rentals[0].minimum_units = 25; + errorAt(fleet, "rentalUnits", "/rentals/0/maximum_units"); + }); + + it("checks offer windows by instant, including timezone offsets", () => { + const fleet = fixture(); + fleet.rentals[0].valid_from = "2026-09-13T12:00:00Z"; + fleet.rentals[0].valid_until = "2026-09-13T14:00:00+02:00"; + errorAt(fleet, "rentalPeriod", "/rentals/0/valid_until"); + fleet.rentals[0].valid_until = "2026-09-13T13:00:00+02:00"; + errorAt(fleet, "rentalPeriod", "/rentals/0/valid_until"); + fleet.rentals[0].valid_until = "2026-09-13T15:00:00+02:00"; + expect(validate("openfleet", fleet).ok).toBe(true); + }); +}); diff --git a/packages/validators/src/openfleet.ts b/packages/validators/src/openfleet.ts new file mode 100644 index 0000000..cb72f0b --- /dev/null +++ b/packages/validators/src/openfleet.ts @@ -0,0 +1,72 @@ +import type { ErrorObject } from "ajv"; + +type MemberReference = { kind: "openagent" | "openswarm"; id: string }; + +// Called only after the JSON Schema has checked the shape. Keep the checks +// which need sibling values here; uniqueItems cannot enforce identity when +// two entries have different URLs or metadata. +type FleetReferences = { + members: MemberReference[]; + rentals?: Array<{ + id: string; + scope: { kind: "fleet" } | { kind: "members"; members: MemberReference[] }; + minimum_units?: number; + maximum_units?: number; + valid_from?: string; + valid_until?: string; + }>; +}; + +function memberKey(member: MemberReference): string { + return JSON.stringify([member.kind, member.id]); +} + +export function validateOpenFleetReferences(data: unknown): ErrorObject[] { + const fleet = data as FleetReferences; + const errors: ErrorObject[] = []; + function report(keyword: string, instancePath: string, message: string) { + errors.push({ keyword, instancePath, schemaPath: "#/openfleet-semantics", params: {}, message }); + } + + const members = new Set(); + fleet.members.forEach((member, i) => { + const key = memberKey(member); + if (members.has(key)) { + report("uniqueMember", `/members/${i}`, "duplicates an existing member kind and id"); + } + members.add(key); + }); + + const rentals = new Set(); + fleet.rentals?.forEach((rental, i) => { + const path = `/rentals/${i}`; + if (rentals.has(rental.id)) { + report("uniqueRental", `${path}/id`, "duplicates an existing rental id"); + } + rentals.add(rental.id); + + if (rental.scope.kind === "members") { + const scopedMembers = new Set(); + rental.scope.members.forEach((member, j) => { + const key = memberKey(member); + const memberPath = `${path}/scope/members/${j}`; + if (!members.has(key)) { + report("memberReference", memberPath, "must reference a member of this fleet by kind and id"); + } + if (scopedMembers.has(key)) { + report("uniqueMember", memberPath, "duplicates a member in this rental scope"); + } + scopedMembers.add(key); + }); + } + + if (rental.maximum_units !== undefined && rental.maximum_units < (rental.minimum_units ?? 1)) { + report("rentalUnits", `${path}/maximum_units`, "must be at least minimum_units"); + } + if (rental.valid_from && rental.valid_until && Date.parse(rental.valid_from) >= Date.parse(rental.valid_until)) { + report("rentalPeriod", `${path}/valid_until`, "must be later than valid_from"); + } + }); + + return errors; +} diff --git a/packages/validators/src/schemas.ts b/packages/validators/src/schemas.ts index d1c8290..c8d444f 100644 --- a/packages/validators/src/schemas.ts +++ b/packages/validators/src/schemas.ts @@ -8,6 +8,7 @@ * could not load a single schema. */ import agentSchema from "@logicsrc/schemas/agent" with { type: "json" }; +import openfleetSchema from "@logicsrc/schemas/openfleet" with { type: "json" }; import accountAuditEventSchema from "@logicsrc/schemas/account-audit-event" with { type: "json" }; import accountGrantSchema from "@logicsrc/schemas/account-grant" with { type: "json" }; import accountProviderSchema from "@logicsrc/schemas/account-provider" with { type: "json" }; @@ -70,6 +71,7 @@ import credsAuditEventSchema from "@logicsrc/schemas/opencreds-audit-event" with export const schemas = { agent: agentSchema, + openfleet: openfleetSchema, "account-audit-event": accountAuditEventSchema, "account-grant": accountGrantSchema, "account-provider": accountProviderSchema,