Skip to content

feat(delivery): a GitOps rollout delivers the signed pair through the composition interface - #1240

Open
antoinetoussaint-byte wants to merge 27 commits into
mainfrom
handoff/wiki-builder-install-20261029
Open

antoinetoussaint-byte wants to merge 27 commits into
mainfrom
handoff/wiki-builder-install-20261029

Conversation

@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor

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.delivery names saas/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 answers module "wiki": not found.

What

  • The cell names the delivery endpoint: top-level delivery_target mirrors host.delivery verbatim, so no consumer (infra's mesh grant) guesses the port from an endpoint's api (0e5f28e).
  • codefly delivery prepare|apply: one ApplyManifestRequest per manifest, both halves signed where release custody is, the credential read from the projected token at execution, raw, never printed (3c41a3a).
  • A publish toward a host whose delivery endpoint declares codefly.dev/composition writes 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).
  • Also carries fix(environments): hold scoped names and managed services to the selected environment #1226 (scoped names and managed services held to the selected environment), which this branch was built on.

Classification

fix/feat at 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.Transient for retry. Not built: a second endpoint, a receiver change, a REST fallback, a hardcoded port.

Not done here, stated plainly

  • pairedDeliveryImage pins 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.
  • In a paired environment the envelope Job still POSTs its REST route to the endpoint host.delivery names; 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/gitops ok (127s), cmd/delivery ok, cmd ok, pkg/environments ok. New pkg/gitops/paired_delivery_job_test.go (paired publish writes one paired ConfigMap per manifest incl. a withdrawal and a Job running delivery apply with no DELIVERY_* 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 in cmd/environment/import.go:213.

Supersedes #1226 (same commits, no CI ran there).

🤖 Generated with Claude Code

antoinetoussaint-byte and others added 21 commits October 28, 2026 19:43
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>
antoinetoussaint-byte and others added 6 commits October 29, 2026 13:33
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant