A module (or a composed solution) is present on a host because a signed
module manifest declared it — never because its process announced itself. One
manifest per module instance says where it runs, which workloads run as it,
where its endpoints are, and what authority it asks for.
solutionhost/ owns the type, its strict reader, its
rules, its canonical encoding, the DSSE signature over it, and the decisions a
host makes when one arrives. Core does not render one (the CLI does), sign one
(the delivery pipeline holds the key), or apply one (the host does).
A mint answers four questions from two inputs plus the organisation's
installation: who is calling (the workload credential, attested by core
workloadidentity), which module it is (the attested namespace and
account match exactly one workload of one manifest), may it act for this
organisation (an active installation), and with which scopes (what the
manifest requests, intersected with the administrator's approval). There is
no build approval at runtime: no image, release or artifact digest is
compared when a credential is minted. "Only approved images run" is
deploy-time admission, outside core.
Schema codefly/module-manifest/v1 (solutionhost.SchemaModuleManifestV1),
conventionally module-manifest.codefly.yaml (solutionhost.FileName):
schema: codefly/module-manifest/v1
binding: acme.local.wiki # instance id (workspace.env.instance), stable
alias: wiki # route alias: one lowercase path segment
kind: module # module | solution
generation: 7 # monotonic per binding, set by the publisher
removed: false # true = withdrawal: identity fields only
host: {coordinate: obin/local/dev, domain: obin}
release: {publisher: obin, name: wiki, version: 0.1.0}
group: {id: knowledge, title: Knowledge, order: 1} # solutions only, optional
frontend: # optional; payload is generated by the renderer
title: Wiki
order: 10
exposed_module: ./Page
host_contract: 1
payload: {artifact: wiki-frontend, digest: "sha256:…", media_type: application/gzip}
workloads: # every workload that runs as the module's principal
- service: backend # also the key an endpoint routes to
container: backend # the one container that authenticates
spiffe_id: spiffe://cluster.local/ns/platform-obin-wiki/sa/backend
endpoints: # what a host or gateway may route to, with the address
- {name: rest, service: backend, api: rest, address: "http://localhost:5123"}
authority: # what the module asks for; absent asks for nothing
principal: wiki
bindings:
- id: model
audience: modelservice
operations: {invoke: [modelservice.profiles:invoke], headless: [modelservice.profiles:invoke]}
queues: [wiki.index]
namespaces: [wiki]
resource_kinds: [wiki.pages]solutionhost.Parse decodes strictly — an unknown field, a repeated key
(aliases resolved), a merge key, an explicit null, a fractional number or a
second document is refused rather than repaired — and validates. Rules
(solutionhost.ModuleManifest.Validate):
- the binding is an instance id (never
base, the collision checker's exempt owner); the alias is one lowercase path segment; the kind ismoduleorsolution; the generation is at least 1; the host coordinate and domain are names; the release publisher and name are single segments and the version is exact semver; - a live manifest declares at least one workload, each a (service, container)
pair and nothing of where it runs; services are unique. A cell spells the
rest by rule — the namespace named after the module, the ServiceAccount named
after the service — and the rule reads both ways in
workloadidentity(Workload.KubernetesNamespace,Workload.KubernetesAccount,Workload.SPIFFEID,FromKubernetes), so no typed SPIFFE ID is declared and a mint finds a workload by that rule, by service (solutionhost.ModuleManifest.WorkloadNamed); - each endpoint names a declared workload's service and carries one address;
- the frontend and the group are held to their block rules (bounded texts,
decimal numbers, surfaces, the payload's digest and media type; a group only
on a solution);
solutionhost.ParseFrontendreads a component's standalone frontend file through the same rules, and refuses apayloadin it; - the authority principal is an opaque id; binding ids are unique; each
binding requests at least one of
invoke,lookup,headless, each with at least one<resource kind>:<action>scope; queues, namespaces and resource kinds are unique single-line names. The principal equalling the module name is the CLI's rule: core holds only that it is well formed; - a withdrawal (
removed: true) carries its identity and nothing else: no workloads, endpoints, authority, frontend or group.
solutionhost.Marshal is the one writer: it validates, encodes, and reads the
bytes back, returning them only when they are the same manifest.
The presence (codefly/solution-host-binding/*), authority
(codefly/solution-authority/*) and endpoint-delivery
(codefly/endpoint-delivery/*) documents are gone, with no compatibility
reading. A document declaring any version of them is refused with
solutionhost.ErrSchema and the remedy: re-render it with a current Codefly
CLI. Signing refuses them the same way, so neither end can produce or accept
one.
solutionhost.ModuleManifest.CanonicalBytes is JSON with object keys in name
order at every depth and every set sorted (workloads by service, endpoints by
service and name, bindings by id, scopes, queues, namespaces, resource kinds,
frontend surfaces), so the bytes move only when the manifest does.
solutionhost.ModuleManifest.Digest is their SHA-256. They are what a
signature covers.
The signature is DSSE with exactly one signature, payload type
application/vnd.codefly.module-manifest+json, verified offline against
pinned public keys (Ed25519 or ECDSA P-256) using only the Go standard
library. Each pin names the ownership domains its key may deliver under; the
manifest's host.domain asserts its domain, and the pin is what bounds it.
A producer:
payload, err := solutionhost.SignedPayload(manifest)
envelope, err := solutionhost.SignEnvelope(payload, signer) // a Signer the pipeline holds
carrier, err := solutionhost.Carrier(payload, envelope) // refuses a non-canonical payload
encoded, err := solutionhost.MarshalSigned(carrier) // JSON, under solutionhost.SignedFileNameA host:
keys, err := solutionhost.NewPinnedKeySet(solutionhost.PinnedKey{
KeyID: "release", PublicKeyPEM: pem, Kind: solutionhost.KeyKindRelease, OwnershipDomains: []string{"obin"},
})
verifier, err := solutionhost.NewVerifier(keys)
carrier, err := solutionhost.ParseSigned(body)
delivered, err := solutionhost.VerifyDelivered(ctx, carrier, verifier)
manifest, err := delivered.Manifest() // re-derived from the attested bytes, fresh each callsolutionhost.VerifyDelivered returns nothing unless the signature verifies
under the pin its key id selects, the payload is the canonical encoding of a
valid manifest (solutionhost.ErrNotCanonical otherwise, even under a genuine
signature), and the signer is pinned for the manifest's domain
(solutionhost.ErrWrongDomain). A solutionhost.Delivered stores the attested
bytes, not a parsed value, so nothing a caller does to the manifest it was
handed changes what the host stores (solutionhost.Delivered.Payload).
solutionhost.ManifestFromVerified is the document half alone, for bytes a
host already verified and stored.
decision, err := solutionhost.Admit(coordinate, previous, candidate) // previous: nil for a new bindingsolutionhost.Admit takes the host's coordinate, the manifest it applied for
the candidate's binding (or nil) and the candidate, and answers:
| Case | Answer |
|---|---|
| no previous manifest | DecisionAccept |
| higher generation | DecisionAccept (a withdrawal at a higher generation too) |
| same generation, same canonical bytes | DecisionReplay |
| lower generation | ErrStaleGeneration |
| same generation, different content | ErrRewrittenGeneration |
| candidate names another coordinate than the host, or than the binding's | ErrWrongHost |
| candidate names another domain than the binding was applied under (a withdrawal included) | ErrWrongDomain |
| previous is a withdrawal and the candidate a later generation | ErrWithdrawn |
| previous is invalid or for another binding | ErrAppliedUnusable (the host's own state) |
A withdrawal is terminal: everything keyed on a binding — installations,
grants — would otherwise re-attach to a new instance that merely reused the
id. A replacement instance gets a new binding. Removal is a manifest at the
next generation with removed: true; absence is never removal, so an
unreadable inbox can never read as "withdraw everything".
err := solutionhost.CheckAlias(reserved, applied, candidate)solutionhost.CheckAlias holds a candidate's alias to the host's routes: not
inside a reserved namespace (codefly reserves codefly, codefly/…,
codefly.…), and not claimed by another live binding on the same coordinate.
The candidate's own applied manifest is the one it replaces; a withdrawal
claims no alias. Only the candidate is judged against the reserved
namespaces, so a newly reserved namespace never blocks an unrelated delivery
because an incumbent sits in it. A collision answers
composition.ErrCollision, like every route collision in core.
No credential, no observation, no health, and no build approval: no image,
release, artifact or started-artifact digest, no approved builds, no build
size, no module pins, no envelope revision. The administrator's approval —
the grant a module's request is intersected with — is the host's record, not
part of this document; see the host's documentation. What a module asks for is
declared in its module contract, which the CLI resolves into authority; see
module-contract.md.
authority.installation_role and authority.edge_dependencies carry the
module contract's validated installation declarations verbatim. The selector
is empty when undeclared, literal none for no standing machine grant, or a
role name. Dependencies reuse modulecontract.EdgeDependency with module
and presence fields; they grant no authority. Core's module-contract parser
validates these declarations; this carrier adds no selection, defaulting or
second declaration validator.
Canonical encoding retains dependency declaration order and includes both
fields in the signed payload and digest. It does not sort, trim or infer them.
Absent fields add no keys; nil and empty dependency lists encode identically,
so older manifest bytes and signatures remain unchanged. CLI owns populating
these fields from the resolved contract and pairing the Authority document;
Authority and the host own installation semantics and catalogue resolution.
Each dependency's module is the exact signed module principal. The existing
host catalogue resolves it to the target and binding; the principal is never
treated as a target UUID, nor replaced by a route alias, binding ID or release.