Repository navigation
feat(delivery): a GitOps rollout delivers the signed pair through the composition interface - #1240
Open
antoinetoussaint-byte wants to merge 27 commits into
Open
antoinetoussaint-byte wants to merge 27 commits into
antoinetoussaint-byte wants to merge 27 commits into
Conversation
Blue m106 reproduced this on released source: `deploy gitops render --all
--env blue` failed with
environment "staging" service-config references ambiguous service "object-storage"
#1223 narrowed the exclusion graph check to the selected environment. The
service-scoped-name and managed-service-key passes in the same function were
left whole-workspace and documented as such. They have the identical failure
shape, and it has now bitten: a bare service-scoped key is resolved against
the WORKSPACE's service graph, so it goes ambiguous the moment a second
module composes a service of that name. The stale declaration belongs to
whichever environment wrote the bare key — staging — but every environment
was held to it, so a correct render of blue was refused over a declaration
that is not blue's and that blue cannot repair from its own side.
Those passes now hold the selected environment, by the same rule the
exclusion pass uses: selectedOrAll, where a nil selection is the same as none
and still holds the whole workspace.
Deliberately unchanged:
- The graph is still built from the whole workspace. A name is ambiguous
because of what the workspace composes; the selection decides whose
declarations are judged against it, never what it contains.
- Every environment's declarations are still decoded and validated for SHAPE
before the graph comparison, so a malformed declaration anywhere still
refuses whatever was selected.
- Module namespaces and host composition stay whole-workspace.
Two regressions, both verified red with only the narrowing reverted. The
render-caller one fails with Blue's exact message; the direct one exercises
ValidateWorkspace and also pins that the owning environment still refuses
when it is selected, and that no-selection still holds everything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed env
Blue reports, and this confirms, that their own environment carries BOTH
"object-storage" and "documents/object-storage" in the same block. Narrowing
holds each environment to its own declarations, so the refusal MOVES to blue
rather than disappearing:
environment "blue" service-config references ambiguous service "object-storage"
That is correct — blue is malformed — but it means this change does not by
itself clear Blue's render, and the commit before this one should not be read
as claiming it does. The qualified sibling does not redeem the bare key: the
bare one still resolves against a workspace composing two services of that
name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…key on import Two halves, both from reproduced defects. NewFlow. The independent review found the narrowing did not reach the path a render actually takes: renderServiceFlow (pkg/gitops/orchestrate.go) goes through NewFlow, which validated managed services on its own call against the WHOLE workspace. A regression driven only through ValidateWorkspace cannot see that call, which is why the first round missed it. ValidateManagedServices now takes the same variadic selection and NewFlow passes its environment: a flow resolves managed services through the SELECTED env.ManagedService and is not answerable for another environment's stale bare key. The regression runs through the real constructor, and pins what must not change with it: the owning environment still refuses when it is the selection; no selection and an explicit nil selection both still hold every environment; and the check is still against the whole composition rather than the run's closure, so an ambiguity involving a module the service does not depend on is still judged for the selected environment's own keys. Verified red with only the NewFlow argument reverted. Import. A producer re-keying `object-storage` as `documents/object-storage` left both: applyContractFields sets what the declaration carries and removes nothing, so the bare key survived and re-importing could not clear it — import was what preserved it. withdrawRenamedServiceKeys infers a rename only when the key is bare, the contract no longer declares it, the contract declares EXACTLY ONE qualified key with that service name, and the two decode to the same value. Everything else is left alone or refused, never reconciled by guesswork: several qualified keys sharing the basename is refused naming all of them, because a contract declaration does not prove the bare key's provenance and picking the first or last would be arbitrary; differing values are refused naming both. Refusals happen before anything is written. A bare key the contract never replaced is the operator's and is untouched. Values are compared by what they decode to, not by serialized bytes: one side is parsed from the workspace and the other freshly encoded from the contract, so a byte comparison reported a conflict for every rename. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The delivery documented the selected-environment narrowing but not the importer half. The `environment import` reference now states the four conditions under which a bare key is withdrawn, and the three cases that are refused or left alone instead — several qualified destinations, differing values, and a key the contract never replaced — together with the rule that a contract declaration does not prove a bare key's provenance, that refusals precede any write, and that values are compared by meaning rather than bytes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Independent review reproduced two blockers against it, both real, and neither is fixable by tightening the heuristic. Distinct-service data loss. A valid `depot/object-storage` bare config with BUCKET=shared was silently removed when an equal-valued `documents/object-storage` arrived. Equal values plus a single qualified replacement does not establish that the bare key was ever the contract's: a key meaning a genuinely different service, whose values happen to match, is indistinguishable to this rule and is deleted. Unsupported service-secrets migration. A bare-to-qualified rewrite there writes a workspace that ValidateWorkspace then rejects as an unknown service, because RemoteRef changes what is owned and what is a credential, falling back to object-storage/PASSWORD while consumers remain bare-only. service-secrets should never have been in the block list. I checked whether provenance could ground the rule properly before falling back: it cannot. stampProvenance writes one head comment on the environment node — coordinate, timestamp, re-run hint — and records nothing per key, so which keys a prior import wrote is not recoverable from the schema as it stands. So the whole change is withdrawn rather than narrowed. Retiring a bare key needs owner identity or recorded provenance, or an explicit maintained choice by the operator; it cannot be inferred from a name suffix and value equality. cmd/environment is byte-identical to main again, and the import section of docs/commands.md with it. The selected-environment and NewFlow managed-service fixes are untouched and stand on their own: the ambiguity guard stays fail-closed, which is what keeps the unsafe cases visible rather than papered over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… intent A producer that re-spells a service-scoped entry — `object-storage` becoming `documents/object-storage` — leaves both, because the importer merges by key and removes nothing. The bare key then resolves against a workspace composing two services of that name and is refused as ambiguous, and no ordinary re-import clears it: merging is what preserved it. That is Blue's render, which the selected-environment fix correctly moves onto blue rather than clearing. `--retire-service-key <block>:<bare>=<module>/<service>`, repeatable, is the operator stating that one bare key is the old spelling of one qualified service. Nothing is inferred. Equal values are never compared, because a bare key can name a genuinely different service whose values happen to match — which is exactly how the previous, withdrawn attempt deleted a valid depot/object-storage entry. It deletes the WHOLE named entry. Resolution copies a bare entry's values and then overlays the qualified entry's, so a bare entry can supply keys the destination does not declare; those stop being set, and the run names them before writing. The replacement is the contract's, verbatim: nothing is copied or renamed across. Blocks are service-config, service-identity and service-mesh. service-secrets is refused by name — a remote reference is not a spelling of a service name, and rewriting it yields a workspace validation rejects. For service-mesh, shape validation already demands qualified keys, so retirement there repairs an invalid declaration rather than migrating a once-valid form. Every refusal precedes the write: malformed directives, a key named twice or twice with conflicting destinations, an unsupported block, a bare key the environment does not declare, a bare key the incoming contract STILL declares (current, not retired), a destination the contract does not declare under that block, and a destination the workspace composes no service for. Destination existence is proved with LoadServiceWithModules against the loaded composition rather than a name index, since an index built from module references can report a qualified name nothing composes. That inspection is read-only: both the workspace load and its child loads run under WithWorkspaceMigrationRefusal, because Core's default loader migrates a legacy flat workspace by rewriting the workspace file and deleting the legacy module file. Proven by test — with the refusal removed, a REFUSED import rewrites workspace.codefly.yaml and deletes module.codefly.yaml. The directive is one-shot: it needs the bare key present, so a second run refuses, and the refusal says to omit the flag. Readiness validation runs after the write, so a non-zero exit can still mean the retirement committed. Provenance records each retirement as a dated operator action on its own line, deliberately outside the re-run hint, which stays replayable. It is a note about one import — not an audit log, not evidence of historical ownership, and read by nothing. Without directives the importer behaves exactly as it does on main, which a test pins. Docs: commands.md, configuration-contract.md, and the generated cli-reference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was added as the retirement destination check's graph seam, then left behind when that check moved to Core's LoadServiceWithModules — which proves membership against the loaded composition rather than a name index built from module references. Nothing calls it, and its comment claimed the importer did. The unexported servicesByName it wrapped is unchanged and still serves both validators in this file. No retirement semantics, flags, refusals or docs are touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
My wording claimed a refused directive and --dry-run "leave every file untouched". That is wider than what the code guarantees, and Core says so itself: WithWorkspaceMigrationRefusal's own doc ends "hosts remain responsible for the effects of any resolver they attach to the context." What holds is that the workspace is not modified — workspace.codefly.yaml, the module files and the service sources are exactly as they were, which the legacy-workspace test proves by snapshotting the tree around a refusal. What does not hold is a claim over the whole filesystem. Proving a destination exists reads the composition, reading a composition resolves a pinned module reference, and ResolveWorkspaceReference -> ensurePinnedArtifact -> clonePinnedArtifact clones an uncached pin into the CLI-managed module cache root. That is dependency-cache bookkeeping outside the workspace, identical to the acquisition any other reader of the same composition performs, and an unresolvable pin refuses rather than being fabricated. Docs and the code comment now say that. No retirement semantics, flags, refusals or tests change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from the implementation review, both on the public surface. Reused-flag state. The import command is a package-level var and cobra keeps each parsed flag's value and Changed bit on its FlagSet, so a process running the command twice carried the first run's flags into the second. Separate processes were never affected, which is why only the two-Execute path showed it. `--retire-service-key` inherited its directives and then failed the one-shot check against a key the first run had already retired. It is not specific to the new flag, and writing the regression surfaced a pre-existing instance: an earlier `--cell-contract` run left that flag Changed, so a later `--coordinate-contract` run in the same process failed "pass only one". `--namespace` has the same exposure, since Changed is what distinguishes asserting a namespace from omitting it. So every flag of this command is cleared after a run, registered first in RunE so an early refusal resets too. Acquisition during inspection. Proving a destination exists reads the composition, and the host's resolver answers a pinned `workspaces:` reference by cloning it into the module cache — a dry-run that fetched a dependency in order to decide it would refuse is not the read-only check this operation promises. The inspection now replaces the resolver with one that refuses, next to the migration refusal it already carried, using Core's existing WithWorkspaceResolver rather than a new resolver. Prepared sources are unaffected: a local path override or an already-materialized module resolves from disk and never asks. Docs are realigned from the interim narrowing to what the code now does: nothing acquired, nothing written, and the refusal tells the operator to materialize and import again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Blue's auth-gateway had a correct ServiceAccount, a correct codefly-workload-identity projection and a correct read-only mount at /var/run/secrets/codefly — and could not register a hop. Nothing told the process where the token was. The mount was never the whole contract. sdk-go's ProjectedTokenFile is a path VALUE its caller supplies and reads no environment itself (mint.go:121-135, where an empty path is an explicit misconfiguration). Hop's RunCredential reads only CODEFLY__WORKLOAD_TOKEN_FILE and refuses when it is absent, inferring no Kubernetes default. So the deployed workload acquired no publication lease and its Front.Ready refused. The native run path has always supplied that variable; the renderer never did. projectIdentityToken now populates podProjection.env — a field that existed for exactly this, with no production user — so the path travels with the mount that creates it, into the same container. Its value is derived from identityTokenMount and identityTokenFile rather than written twice, so the projection and the path it advertises cannot drift. The constant moves to pkg/gitops as WorkloadTokenFileVariable and cmd/run consumes it, leaving one definition repo-wide instead of two that could diverge. That alone did not render: the render's own credential guard refused it. isCredentialKey classifies the name as credential material because it contains TOKEN, and a literal under such a key is refused — correctly, since that is how a secret becomes a plaintext manifest. A mount path is not secret material, so the classification is corrected for ONE exact pair: projectedTokenPathEntry admits this name carrying exactly projectedTokenPath() and nothing else. There is no *_FILE exemption and isCredentialKey is unchanged. holdTokenPathToItsMount keeps that allowance from becoming a way to put the path anywhere: a container declaring the variable without mounting the token at that path is refused by name, at render rather than at runtime, where it would read as a refused credential rather than a missing file. Tests: the rendered env is asserted against the rendered mount in all three overlay shapes, so a literal that drifts from the mount fails; a service with no identity is told nothing; the allowance is held to one pair (a different value, another credential-named key, a real token literal, the mount directory instead of the file); and the variable is refused on a container mounting nothing or mounting elsewhere. Docs: docs/configuration-contract.md states the two halves, why the mount alone is insufficient, and where the allowance stops. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…arrier Core's signed-carrier successor adds InstallationRole and EdgeDependencies to solutionhost.Authority, so the CLI can now carry what a module contract declares into the delivered manifest. This is ONE of the two carriers the paired agreement rule compares; the other is AuthorityManifestV1 fields 8/9 at composition_delivery.go:145, which needs Authority's generated client and is NOT wired here. Carried verbatim, and that is the whole point. The rule compares both signed carriers byte-for-byte — empty and "none" differ, nil and [] are equivalent zero edges, and a one-sided nonempty declaration refuses. Core already validated the declaration (contracts/module rules.go: strict literal selector, no trim or case-fold) and the host resolves EdgeDependency.Module as a principal through its catalogue. So the CLI trims nothing, defaults nothing, sorts nothing, deduplicates nothing and resolves nothing: any normalization here would make the CLI the party that broke the comparison. Core pin moves to v0.19.17-0.20261029043538-2aa2e9aaf950 with pkg/conformance/matrix.json bumped to v0.19.17 in the same commit: TestMatrixCoreMatchesGoMod compares the matrix against go.mod's release line and fails only when the conformance suite runs, so a clean build and vet are not evidence. CLI module graph delta is one line, 491 modules unchanged — Authority's 11 indirect requirements do not reach this consumer. Tests: the selector and the edge list reach the Authority block and the delivered manifest with declaration order preserved (robin before annotations, which sorting would reverse), and "none" is carried as a literal distinct from an undeclared empty. Writing them exercised Core's own validation twice — an undeclared role and a role granting outside its ceilings are both refused there, which is where that belongs. NOT deliverable on its own: rendering with only this carrier wired would deliver a one-sided declaration, which the paired reader refuses after Authority's successor and silently drops before it. Delivery waits on the second carrier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
87658ff wired one carrier and said plainly it was not deliverable alone. This wires the second, so the declaration can be delivered at all. A module's installation declaration is compared by the paired agreement rule across TWO signed documents, and neither wins: InstallationRole must match byte-for-byte (empty and "none" differ), EdgeDependencies must match in length and in each ordered Module/Presence pair, nil and [] are equivalent zero edges, and a nonempty declaration on only one carrier — including an absent sidecar — refuses before persistence. So a producer that fills one and not the other is worse than useless: before Authority's reader successor the declaration is silently dropped, and after it the pair refuses. The second carrier is AuthorityManifestV1, which this CLI marshals and signs itself (composition_delivery.go) from Authority's generated package, not Core's. gitops.InstallationDeclaration reads the selector and the edges from the module's own contract; CompositionDelivery gains an installation provider beside methods/ceilings/roles/ownerAware, wired at the one construction site in cmd/run; and the declaration now contributes to the "declares nothing" test, so a module whose only declaration is an installation one still gets a document. Carried verbatim on both. Core validated the declaration already and the host resolves EdgeDependency.Module as a signed principal through its catalogue, so nothing here trims, defaults, sorts, deduplicates or resolves. The one normalization is deliberate and required: an empty edge list is delivered as nil, because a repeated proto field cannot carry absent-versus-empty and the rule defines them as equivalent — so both spellings serialize identically rather than one becoming a mismatch against the other carrier. Pins: core v0.19.17-0.20261029093714-9cdb8e50bd56, whose only change over 2aa2e9aaf is the fixture principal the CLI's wire kit requires — the kit renders module "assistant" and the new install fixtures had declared principal "example", which the principal guard refuses. That guard is load-bearing and was not relaxed, and the kit was not taught to rewrite documents; Core fixed the fixtures. authority-client moves to v0.1.1-0.20261029092314-21ce6d9284ed for AuthorityManifestV1 fields 8/9. pkg/gitops, pkg/environments, cmd/environment and cmd/run all pass; vet and gofmt clean. Fifteen NewCompositionDelivery call sites updated for the new provider. Still not qualified end to end: the real paired producer/reader signature round-trip and mismatch handling need Authority's reader successor on the same pins, and Authority's own tests begin at a constructed document rather than a signed CLI one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… rule
My previous commit said the paired producer/reader round-trip was unqualified.
This closes it, against Authority's real rule rather than my expectations.
The test takes what the PRODUCER emits — the Core manifest's Authority block
from a real render, and the AuthorityManifestV1 bytes from the same
InstallationDeclaration reader the delivery uses — and runs the pair through
delivered.Module at authority-core v0.0.0-20261029092314-21ce6d9284ed, whose
sameInstallationDeclaration is the agreement rule itself. Authority's own
suite begins at a CONSTRUCTED document; this begins at emitted bytes, which is
the gap their suite names and leaves open.
Covered, each starting from a pair that agrees so the refusal is the
disagreement and nothing else: selector mismatch; selector one-sided; empty is
not none; edges one-sided; edge identity; edge presence; edge ORDER; edge
count; an absent document against a declared selector; the legacy both-absent
pass; and nil/[] equivalence asserted on both carriers.
Verified load-bearing by mutating the producer, not the test:
- drop the selector from the proto carrier only
=> "installation_role differs between the signed manifest and authority document"
- sort the edge list in the proto carrier only
=> "edge_dependencies differs ... at entry 0"
Both restored. So the proof fails when the producer breaks agreement, which is
the only thing that makes it worth having.
What it does NOT prove, stated in the test: the proto -> delivered.Document
mapping belongs to the Authority service rather than its shared library, so
documentOf reproduces it here. A divergence between that mapping and the
host's own would not be caught. Signature verification and carrier pairing
(VerifyDocument, digest/generation) are Authority's and unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on gaps The independent review of 22c1ff9 named two gaps. 8c81d3c closed the paired agreement but touched only tests and go.mod, so both stood. Closing them. ACTUAL-WIRE REGRESSION. The agreement proof marshals the sidecar proto itself, so it could not catch the three regressions the review listed. This drives the real CompositionDelivery with a real contract read, delivers through NativeDeliverer, and reads the declaration back off the signed authority document the peer received — the only thing it asserts is what went over the wire. Cases: a named selector with ordered edges; the none selector alone as reason enough for a sidecar; edges alone as reason enough; and a unit declaring nothing producing no sidecar at all. Verified against the production code, not the test: omit both proto fields in CompositionDelivery.declaration => 3 subtests fail revert its declaration-presence condition => 3 subtests fail The third regression — dropping the provider where the delivery is CONSTRUCTED — is invisible to any test that supplies the provider itself, and its symptom is a delivery that silently carries no declaration while the core manifest carries one, refused later at install far from the line. A textual guard in pkg/conformance holds every command file that constructs a composition delivery to also reading installation declarations, with comments stripped so a comment naming the reader does not satisfy it. Verified by dropping the callback in cmd/run/launcher.go: the guard names the file and the consequence. DOCUMENTATION. docs/module-manifests.md now describes the declaration beside owner_aware and roles: the three selector states and core's strictly literal matching (empty is not none; " installer " is refused, not trimmed); edge_dependencies as presence-only, granting nothing, each module the exact signed principal the host resolves, never inferred from bindings; both signed carriers with their field numbers; declaration order preserved because the list is a sequence and sorting changes what is signed; byte-for-byte comparison by authority with nil/[] equivalent, which is why an empty list is delivered as absent; a declaration being reason enough for a document to exist; and the scope limit — the rendered-manifest delivery job is not a qualified paired producer. pkg/gitops passes. The four pkg/conformance failures are pre-existing and environmental: an untracked .lazybox/checkouts/qualification-48a517cd sibling checkout that the repository guards walk; every message names it, and they pass in a clean worktree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the pre-merge pseudo-version v0.19.17-0.20261029093714-9cdb8e50bd56 with the released tag at merged 6d3af256. The module sum matches the publisher's receipt exactly: h1:D2u6bC+HFWIqgGjNSx+Pn/AWWFFPbiTBLZp7xUvhNjk=. Verified the release carries what the CLI depends on, rather than assuming a tag at a merge commit is a superset of a branch commit: the declaration fields on contracts/module Contract and Resolved, the same fields on solutionhost.Authority, canonical() cloning EdgeDependencies with slices.Clone so declaration order survives into the signed bytes, and zero fixtures left declaring principal "example" — the correction the CLI's wire kit required. pkg/conformance's matrix guard already read v0.19.17, so the release line is unchanged and no matrix edit belongs here. pkg/gitops, pkg/environments, cmd/environment and cmd/run all pass; vet clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SaaS's live read-only diagnosis made this load-bearing rather than a coverage gap. Their host holds the wiki manifest at generation 1, received 2026-10-24, with digest and generation matching the applied catalogue and authority_document/envelope NULL. A host will not replace a record it already holds unless a LATER generation arrives — so if editing only the installation declaration left the generation where it was, a repaired delivery would re-send generation 1 and the host would keep the policy-less record forever. TestNativeGenerationIncludesEachAuthorityPolicyInput enumerates method, ceiling, role and owner-aware, and passes nil for installation, so nothing covered this. The new test edits only the declaration, three times, through the real CompositionDelivery and NativeDeliverer, and reads the generations off the signed carriers the peer received: declaring nothing => generation 1, NO authority document add only installation_role => generation 2, document present add only an edge => generation 3, document present So the repaired route does carry both signed documents at a newer generation, which is what SaaS asked to be sure of. The first row also reproduces how a NULL document arises: a module declaring no policy at all delivers no authority document, which is exactly the generation-1 record they found. One thing this does NOT fix, and nobody should expect it to: authority refuses to compose a solution that declares no installer role — decide/manifest.go:66, "%s declares %d role(s) and none is %q: a solution declares an installer role". A delivery carrying both documents at generation 2 will populate authority_document, but if the unit still declares 0 roles the composition is refused on that separate ground. That is the unit's declaration to fix, not the delivery's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…esses it A consumer deriving what may reach the host's delivery API needs a port, and the cell file gave it nothing to resolve: the per-workload `verifier` flag marks the workload that serves the delivery endpoint but drops the endpoint name, and one workload serves every endpoint of its service, so the flag is identical whichever endpoint `host.delivery` names. The only way left was to guess — by the endpoint's `api` — which is how a `connect`-typed delivery API came to be granted on a REST port. So the cell carries `host.delivery` verbatim as top-level `delivery_target`: the same `<module>/<service>/<endpoint>` spelling the render resolves, and no address and no port, because a port in this document is the hardcoding the cell file exists to remove. It is always written — a host block without a well-formed delivery identity does not validate — so absence means an emitter older than the field rather than a cell without one. `Parse` stays permissive on it, since a render reads the previous cell file to compute removals and that one may predate the field. The regression renders the same cell twice, differing only in which of `billing/ledger`'s two endpoints the host names, and asserts the carried identity follows while the `verifier` flag and the inventoried endpoints are identical either way — so it fails on the fact the field adds, not on plumbing. Removing the one production line fails both subtests. The docs also record two limits of the rendered delivery Job found while tracing this: it posts `/platform/_delivery/manifest` whatever the declared endpoint's `api` is, and it carries the Core signed carrier alone, so a module delivered that way reaches the host with no roles. Both are the same gap — the render never reads the declared endpoint's `api`, and the one producer that speaks the composition interface is wired only into a native run. docs/module-manifests.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… read at execution A module's roles reach a host on one carrier only: the proto authority document the composition interface takes beside the Core manifest. Core's own Authority has no roles field, so a delivery over the host's HTTP manifest route composes the module with no roles at all — which is what a GitOps rollout does today, and why a host reports a module that declares installer, editor and viewer as "0 roles and none installer". Until now the one producer of that half was wired into a native run, so nothing outside `codefly run` could send a pair. Two verbs now can: codefly delivery prepare --overlay <rendered overlay> --out <dir> --env <name> codefly delivery apply --documents <dir> --address <host>:<port> --identity-file <token> prepare reads the Core carriers a render already settled and the declarations the modules hold, and writes one ApplyManifestRequest per manifest. apply sends them to host.composition.v1.CompositionService/ApplyManifest with the generated client — over gRPC, because a Connect server answers the gRPC protocol on the same h2c listener, so the address is the endpoint host.delivery already names. No new endpoint and no receiver change. The two halves are signed where release custody is and sent from inside the cell, after the module's workloads exist. The credential is the one field that cannot travel between those two moments: it is a projected ServiceAccount token with a lifetime of minutes, so one written into a rendered manifest would be a published secret and would be expired by the time the manifest synced. A prepared document therefore carries `credential: ""` — a document that holds one is refused where it is read rather than sent — and the sender reads the token from the projection at execution, once per request, so a rotation between rounds is what the next round sends. It goes raw, because the host adds the bearer scheme itself, and it is never printed, never an argument and never in an error. One rule, two producers. SignedAuthorityDocument (binding the declaration to the module, the generation and the digest of the exact Core bytes) and AuthoritySources (the five declarations read) are each extracted to one implementation, used by the native run and by prepare, because the host compares the pair under a single rule and two copies would be two ways to disagree with it. A test holds them to it: both producers must sign byte-identical documents and envelopes. Retry follows the one classification the run already had (HostStatusError.Transient): only unavailable, out of time and out of capacity are asked again. Every other code is the host's answer — an invalid document, a refused carrier, a stale or rewritten generation, a withdrawal it keeps terminal. FailedPrecondition is among them, and it is the status a Connect transport renders as 412, which the shell runner's verdict set (400|401|403|409|422) did not hold: it retried a terminal refusal until its budget was gone. A document unanswered when the budget runs out is a refusal, not a silent pass. Eighteen regressions, no mocks: a real gRPC server with the real generated client in front of it, real documents on disk, a real token file, and the verb itself over a real TCP listener. An unsigned carrier is refused by name rather than prepared Core-only, because a Core-only document would reach the host looking like a success and compose the module with no roles — the defect this path exists to end. Not exposed over MCP, deliberately: every input exists only inside the pod that runs it, and the one thing an assistant could pass is a path to a credential. Still not wired, and recorded in the docs: the render still emits the curl Job, and the delivery Job's pinned image cannot run these verbs — its /bin/codefly is a glibc executable in an Alpine image with no glibc loader, measured on both architectures. docs/commands.md, docs/cli-reference.md, docs/module-manifests.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…delivery Job A module's roles reach a host only on the authority half the composition interface takes beside the Core manifest. A GitOps publish delivered the Core carrier alone, over the host's HTTP manifest route, so a host whose delivery endpoint is its Connect composition interface either never routed the POST or composed the module with no roles — Blue's Wiki delivery failing with `module "wiki": not found`. When the endpoint host.delivery names declares codefly.dev/composition (HostImplements, the reader a native run uses), publish now: - reads each module's declaration from the composed workspace (WorkspaceAuthoritySources) and writes one paired document per manifest (PreparedPairedDocument) in its own ConfigMap, module-manifest-<binding>-paired, so the manifest ConfigMap keeps the shape every overlay reader parses; - renders the manifest Job to run `/bin/codefly delivery apply` from pairedDeliveryImage with the projected token as its identity file, no shell and no DELIVERY_* environment, mounting the paired documents; - refuses by name a paired publish that has no way to read declarations, rather than deliver the Core half alone and look like a success. The cell's per-namespace delivery image follows the same declaration (cellDelivery(paired)), so the cell admits the pod the Job creates. Envelope decision: the generated composition client exposes ApplyManifest only — there is no envelope method — so the envelope Job keeps the shell runner POSTing /platform/_delivery/envelope even toward a composition host. Its IMAGE is still the namespace's carrier, the paired image, because a cell admits one carrier per namespace by its exact declaration and the host's namespace runs both Jobs; the paired image carries every tool the shell runner calls (measured: the script runs in it as uid 65532 on a read-only root). The carrier image follows the environment, the command follows the document kind (pairedRunner), and neither the cell schema nor its consumer changes. Not yet runnable in a cell, and recorded beside the pin and in the docs: the pinned 0.0.6 companion's /bin/codefly executes on linux/arm64 and linux/amd64 but predates `delivery apply` — it refuses --documents as an unknown flag. The pin has to move to a companion republished from a CLI with the verb. This branch's linux/arm64 build of the verb, run inside the 0.0.6 image as the Job runs it (uid 65532, read-only root, 64Mi), starts and refuses an empty document set as designed. Regressions (pkg/gitops/paired_delivery_job_test.go, real files and a real git base branch, no mocks): - TestAPairedPublishWritesAPairedDocumentPerManifestAndTheCodeflyJob: one paired document per manifest, a withdrawal included, its Core half the manifest's own carrier, roles on the authority half; the Job runs codefly apply from the paired image with no DELIVERY_* env and mounts only paired documents - TestANonPairedPublishKeepsTheShellRunner - TestAPairedPublishWithNoDeclarationsIsRefused - TestEveryDeliveryJobRunsTheImageTheCellAdmits: manifest and envelope Jobs in both modes run the image and labels the cell declares, and only a paired manifest Job runs codefly (fails when pairedRunner ignores the kind) The carrier-contract tests and the delivery-target test now cover both modes and the new Address/API fields. docs/module-manifests.md, docs/commands.md, docs/cli-reference.md Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/commands.md: main (#1228) removed the section on holding an operation to the environment it selected, which this branch had revised; kept this branch's revision, since main's side of the hunk carried no content. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e first that carries the verb pairedDeliveryImage pinned companion 0.0.6, whose static codefly predates `delivery apply`, so a paired Job rendered against it failed on an unknown flag. Every companion bakes the CLI, so core bumped them all (codefly-dev/core#875) and the Companions workflow published 0.0.7 from this branch; its index digest is sha256:1cbd3826…, measured on linux/amd64 and linux/arm64 to answer `delivery apply --help`. A moved pin changes every cell's delivery declaration, so the cell is re-rendered with it; the docs say that instead of "not yet runnable". Testing: GOWORK=off go test ./pkg/gitops/ ok (222s). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Resolves go.mod/go.sum (core v0.19.18 pseudo-version from main, the branch's newer hop/go) and pkg/conformance/matrix.json (main's core row). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
runImport's cyclomatic complexity passed the lint ceiling (33 > 30) once the retirement directives were checked beside the merge. Parsing them and loading the graph they resolve against is one decision, so it is one function; the behaviour, including the refusal without an existing environment and the inspection-context load, is unchanged. Testing: golangci-lint ./cmd/environment/ 0 issues; go test ./cmd/environment/ ok. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…uting publish
A publish prepares its plan twice — once to inspect, once to execute — and
refuses when the two plans differ. The paired document was signed on each
pass, and a signature is fresh bytes (ECDSA, Cloud KMS), so the overlay the
plan was identified by changed between the passes and every paired publish
refused its own plan as stale ("prepared sha256:…, current sha256:…"), measured
on all twelve Blue modules.
The pair now travels the channel the Core carrier already uses: keyed by
carrierKey(paired-delivery, binding, generation) in the plan's Carriers, reused
verbatim by the publish that executes the plan, and refused by name when the
executing publish finds no pair the plan prepared — it signs nothing the plan
did not compare.
Testing: TestAPairedDocumentIsSignedOnceAndCarriedToTheExecutingPublish, with a
nondeterministic (ECDSA) signer: the executing publish signs nothing and
delivers the planned pair byte for byte; a plan that carries the Core carrier
but no pair is refused. go test ./pkg/gitops/ ./cmd/delivery/ ok; golangci-lint
0 issues.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n unchanged
GitOps settlement kept an unchanged manifest at its delivered generation and
reused its carrier. When the base branch had delivered that generation
Core-only and this publish delivers it paired, the host refuses the offer as a
history conflict: the generation is held with no authority document, and the
same generation offered with one differs ("authority document digest
stored=… (present=false) offered=…"), measured on Blue's model binding at
generation 1 while every binding whose content had changed was accepted.
So entering paired settlement from a Core-only record advances once and is
signed again, as the native run's generation record already does on the same
transition; a binding the base branch delivered paired keeps its generation
when unchanged, and a withdrawal keeps its Core-only identity.
Testing: TestAnUnchangedManifestEnteringPairedSettlementAdvancesOnce (Core-only
1 → paired 2 → paired, unchanged, 2). go test ./pkg/gitops/ ok; golangci-lint
0 issues.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…not installed
The composition prepares every runnable for every module that installs
prepared bindings, so a module whose own operations are runnables — robin's
chat and sandbox methods — asked for a person-delegated binding to call itself,
in its request and in the envelope entry written for it. The host refuses the
whole envelope over it: a binding naming its own module is admitted only as
headless work ("invalid installed module operation audience binding"),
measured on Blue where accounts refused the envelope and so never learned the
evaluation principal the wiki review requires.
A module needs no capability to reach its own operations, so a prepared
binding whose audience is the installing module is skipped in both places.
Testing: TestAPreparedBindingAddressedToTheInstallerItselfIsNotInstalled; go
test ./pkg/gitops/ ok; golangci-lint 0 issues.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A module's roles reach a host on one carrier only: the authority document the composition interface (
codefly.dev/composition,host.composition.v1.CompositionService/ApplyManifest) takes beside the Core manifest. The GitOps delivery Job rendered a curl runner POSTing/platform/_delivery/manifest, the HTTP route that takes the Core half alone. On Blue,host.deliverynamessaas/accounts/connect: the Job resolved 8081 correctly, posted a REST path a connect listener does not route, and the one historical delivery that did land (over the REST surface infra granted by assumption) carried no authority document — so accounts composes wiki with "0 roles and none installer" and the install review answersmodule "wiki": not found.What
delivery_targetmirrorshost.deliveryverbatim, so no consumer (infra's mesh grant) guesses the port from an endpoint'sapi(0e5f28e).codefly delivery prepare|apply: oneApplyManifestRequestper manifest, both halves signed where release custody is, the credential read from the projected token at execution, raw, never printed (3c41a3a).codefly.dev/compositionwrites a paired document per manifest into the overlay and renders the Job to run/bin/codefly delivery apply …from the paired companion image; a Core-only publish there is refused by name. The cell's per-namespace delivery image follows the same declaration, so what the cell admits is what the Job runs. The envelope Job keeps the shell runner (the composition client has no envelope method) on the same image (683af61).Classification
fix/featat the owning place: the one producer of the authority half (SignedAuthorityDocument,AuthoritySources) is shared by the native run and by publish/prepare, so the pair the host compares byte for byte cannot disagree. Reused:HostImplements,DeliveryTarget, the carrier/cell seam (deliveryCarrierOf↔cellDelivery),HostStatusError.Transientfor retry. Not built: a second endpoint, a receiver change, a REST fallback, a hardcoded port.Not done here, stated plainly
pairedDeliveryImagepins companion 0.0.6, whose static codefly predates the verb; chore(companions): new versions carrying a codefly with the delivery verbs core#875 bumps the companions and the Companions workflow is dispatched from this ref — the digest is re-pinned in a follow-up commit on this branch once published.host.deliverynames; on a Connect-only port that route may not be served. Unchanged by this PR and to be measured on Blue.Tests
GOWORK=off GOTOOLCHAIN=auto go test -count=1:pkg/gitopsok (127s),cmd/deliveryok,cmdok,pkg/environmentsok. Newpkg/gitops/paired_delivery_job_test.go(paired publish writes one paired ConfigMap per manifest incl. a withdrawal and a Job runningdelivery applywith noDELIVERY_*env; non-paired keeps the shell runner; nil Declarations refused by name; manifest and envelope Jobs in both modes run the image and labels the cell declares — verified red with the fix reverted).go vet, gofmt clean; golangci-lint has one pre-existing finding incmd/environment/import.go:213.Supersedes #1226 (same commits, no CI ran there).
🤖 Generated with Claude Code