How a prepared Runnable binding reaches the process that calls it, and how any large workspace configuration value reaches a process at all. Two parts, and they are independent: a prepared binding carries only what a call needs, and a configuration value too large for a process environment is delivered as a file whose path the environment carries.
Status: design of record for #670. docs/runnable.md owns the
package and binding contracts themselves; this page owns their delivery.
For an operation derived from an owner's service method or route (docs/runnable.md,
"Operations derived from a service method"), codefly generate runnable-bindings
prepares one value per operation for one environment. Composition wires the
owner's resolved address into it, so the caller never names an owner. The value
is written to the runnable-bindings workspace configuration group, keyed
<MODULE>__<OPERATION>, and a caller that declares that group as a workspace
configuration dependency receives it like any other group. No module names
another.
What a call needs is five facts — this endpoint, this method, this identity, this input, this policy — and they have two lifetimes. The four fixed when the operation is installed are the prepared value; identity and input belong to one call and are no part of an installation fact.
The value is a codefly.runnable.v0.PreparedBinding, schema
codefly.runnable-prepared/v3, delivered as its canonical proto3 JSON
(runnable.EncodePrepared):
| Field | What it is |
|---|---|
operation |
the owner coordinates (module, service, endpoint) and the operation as the owner spells it: /pkg.Service/Method or POST /path. This is the audit identity, and what an effect receipt is keyed by |
call |
where one call is sent: the address the environment resolves — an HTTP base URL, since both routes are HTTP — and a typed route: connect (a procedure POSTed as JSON on the owner's Connect endpoint) or rest (the owner's own verb and path: POST or PUT with the plain JSON body, or GET — a read only — with the input object as its query, each member one parameter of that name) |
contract |
the bounded input and output schema, carried whole |
contract_digest |
sha256:<hex> over that contract's canonical form (runnable.ContractDigest) |
policy |
the codefly.runnable.v0.Operation the owner declared — the attempt budget, the authority (audience, invoke and lookup scopes), the completion mode and, when the owner exposed the operation as a tool, that exposure — with every required scope slot already resolved to the exact ids the composition selected (runnable.OperationSpec.ResolveScopeSlots). A writer produces it with runnable.OperationSpec.Policy from the resolved spec rather than copying fields, so every field the schema declares travels; a policy still carrying a slot is refused with runnable.ErrUnresolvedScopeSlots |
A composition resolves an operation's required scope slots once
(runnable.OperationSpec.ResolveScopeSlots) and that one concrete policy is what
both halves of the installation receive: the prepared binding above, and a
resolved policy receipt (codefly.runnable.v0.ResolvedPolicy, schema
codefly.runnable-resolved-policy/v1, written with runnable.EncodeResolvedPolicy
and read with runnable.DecodeResolvedPolicy) carrying the operation, the
concrete policy and policy_digest, sha256:<hex> over the canonical policy
(runnable.PolicyDigest). The host binds the audience and scopes it grants from
the receipt, and never names the writer's or the runtime's schema to do so; the
install-side gate runnable.BindingMatchesResolvedPolicy holds the delivered
binding to the receipt by operation, by policy value and by digest, which is what
proves the two were generated from one resolution. The runtime's own equality
check at installation is a separate check with a separate purpose and is not that
proof. The contract digest is unchanged by any of this: it covers input and output
only.
runnable.EncodePrepared fills the schema and the digest when a writer leaves
them empty and refuses a stated value that is not the right one, the rule a
supplied package digest already follows: conflicting immutable bytes are never
silently repaired.
It is a proto message rather than a hand-written JSON document because it is a contract between core, the CLI, a caller and the SDKs of several languages: protovalidate states its bounds once, every language generates a reader, and a later field is a field on one schema rather than a third parser.
An owner is called with JSON: a gRPC owner on its Connect endpoint
(POST /pkg.Service/Method, application/json), a REST owner on its real
route. Protobuf descriptors existed so a generic caller could resolve a method
for a binary gRPC call, so calling with JSON removes the reason for them and
not merely their bulk. The form that carried them embedded an owner's whole
descriptor closure per operation — about 100 KB decoded, 138 KB as the base64 a
value holds — and five operations of one endpoint were 689 KB of the same bytes
five times over. A prepared value is now a few hundred bytes to a few kilobytes:
the bounded contract is the size of the operation's own field list.
The canonical RunnablePackage and RunnableBinding are not carried either.
They are the immutable installation facts of a launched release
(docs/runnable.md); what a caller of a derived operation does with them is
exactly what the five fields above state directly.
runnable.DecodePrepared reads a value strictly — a field the schema does not
declare is refused rather than dropped, because an unknown field in an
installation fact is either another schema's value or one the reader would have
had to act on. runnable.VerifyPrepared then holds it to four things:
- the wire contract's own bounds, through protovalidate;
- the contract digest covers the contract delivered with it. An owner that
republishes a changed contract derives another digest, so the value prepared
for the contract it used to publish is refused rather than called with a
payload shaped for a contract nobody serves.
runnable.ContractDigestis prefixed byContractDigestFormatV1, so a change in how the digest is computed changes every digest instead of colliding with the previous format; - the address is an HTTP base URL. A gRPC endpoint's bare
host:portreaching that field would address the owner on the port that serves no JSON; - the route is the operation the value names. A
connectprocedure must be the operation's spelling, and arestverb and path must spell it. The two are checked together because they had one cause: a form that read one string as both the audit identity and the dial target refused every REST operation, sincePOST /pathis not a method path; - a
GETis a read: its policy recomputes, its tool (if stated) is read-only and its invoke scopes require onlyread, the rule the derivation applies, held again here rather than trusted; - the policy is inside the bounds an installation enforces, by reading it
back as the
OperationSpeccore already validates. The retry vocabulary follows from the route —google.rpc.Codenames forconnect, HTTP statuses forrest— so it is never restated, and a policy that installs is a policy that would have generated.
Everything fails closed, and the error names the key, the operation or the digest — never a value.
A workspace configuration value has always reached a process as an environment
variable, set at process start: CODEFLY__WORKSPACE_CONFIGURATION__<GROUP>__<KEY>
(and the secret, service and document namespaces beside it). Two platform
limits make that the wrong carrier for a large value:
- Linux refuses
execvewhen oneKEY=VALUEstring is longer thanMAX_ARG_STRLEN, 128 KiB (E2BIG). - macOS caps the environment plus arguments at about 1 MiB (
ARG_MAX).
So a flat configuration value is delivered by file when it is larger than
resources.FileCarrierThreshold (32 KiB), by every emitter, automatically
(EnvironmentVariable.File). The value's own key is then absent from the
environment, and
resources.FileCarrierKey(key) — CODEFLY__FILE__ followed by the key without
its CODEFLY__ prefix — carries the absolute path of a file whose content is
the value, byte for byte. The rule is on the size of the value, never on the
name of a group, so no emitter needs to know what the value is.
| Where | Who writes the file | Where the file is |
|---|---|---|
| a native or Nix process | the core runner, at process start | a per-process 0700 directory, 0600 files, removed when the process ends |
| a Docker container | the core Docker runner, between creating the container and starting it (and before an exec) | copied into the container at /codefly/configuration, owned by the user the container runs as, directory 0500 and files 0400; nothing stays on the host |
| Kubernetes | the core Kustomize render | ConfigMap cmf-<service> (public, defaultMode: 0444) and Secret secretf-<service> (secret, local-apply only, defaultMode: 0440 with the pod's fsGroup) mounted read-only at /var/run/codefly/configuration and /var/run/codefly/secret-configuration |
A file is readable by the user the workload runs as, and a secret file by nobody else:
- Native and Nix. The process runs as the user that wrote the file.
- Docker. A bind mount keeps host ownership: a
0600file is unreadable to a container running as another non-root user, and a file readable on the host is readable by every host user. So the runner copies the files into the container instead, owned by its user. That user is the container'sUser, or the image's; a name is resolved through the container's own/etc/passwdand/etc/group. A user that cannot be resolved refuses the delivery: a value whose reader nobody can name is not delivered. - Kubernetes. Volume files are owned by root. A public file is
0444, readable whatever user the container runs as; it is public configuration. A secret file is0440, and its group is the pod'sfsGroup, which the kubelet adds to every container's groups. The render keeps a declaredfsGroup. Otherwise it sets it to the group the pod runs as (itsrunAsGroup, else itsrunAsUser, from the pod or from every container alike). A pod that declares neither is refused rather than given a world-readable secret.
Two carriers keep their values inline. A configuration document is already
bounded at 64 KiB by its envelope (docs/configuration-contract.md), under the
per-string limit, and counts toward the total below. An --output-env export
is itself a file the SDK loads in-process (LoadRuntimeEnvironmentFile), not
an exec environment, so no platform limit applies to it.
Readers do not change. resources.ResolveFileCarriers resolves every
CODEFLY__FILE__* entry of an environment into the value it carries, and the
SDKs call it when they snapshot the environment (sdk-go's
LoadEnvironmentVariables) and on a direct lookup, so WorkspaceValue,
WorkspaceConfiguration, WorkspaceSecret and configuration documents return
the value whichever carrier delivered it.
- Fixed at process start. The carrier is chosen and written before the process starts, and the SDK reads a file carrier once and keeps it, exactly as it keeps an environment value (codefly-dev/cli#740). A Kubernetes ConfigMap volume that the kubelet later refreshes does not change what a running process reads; a new value reaches a process by restarting it, as it always has.
- Regeneration.
codefly generate runnable-bindingsrewrites the group whole: every prepared value of the workspace and only those.--checkcompares the whole file, so a value prepared against a contract the owner no longer publishes is drift at generation time, before any caller reads it. - One schema, no transition window. A reader accepts exactly
codefly.runnable-prepared/v3. A value of an earlier schema, or carrying a field this schema does not declare, is refused rather than partly read — and a field a caller must not proceed without therefore arrives as a new schema, never as one an older reader tolerates by ignoring it. Regenerating the group is what moves a workspace forward, and a caller that refuses at installation fails before it admits any work.
Everything fails closed, and as early as the information exists:
Native and Nix preparation first keeps the last occurrence of each exact
environment key, matching os/exec precedence: process entries override
environment defaults. The complete winning EnvironmentVariable, including
File and Secret, survives before materialization and size accounting.
Repeated delivery of the same bindings therefore does not double their size.
Kubernetes rendering applies the same normalization within public and secret
categories, after restricted-profile admission and before file splitting,
matching its existing last-key-wins maps. Categories are not merged. Inline
and CODEFLY__FILE__ keys remain distinct; normalization neither resolves nor
conceals conflicting delivery forms. It changes no catalog, threshold or size
limit, and a genuinely oversized effective environment still refuses.
- Plan and render time.
resources.CheckProcessEnvironmentrefuses a process environment in which any oneKEY=VALUEstring reaches 128 KiB, or in which the Codefly-emitted carriers together exceedresources.MaxCarriedEnvironmentBytes(512 KiB), naming the largest keys and never a value. Runners call it before starting a process, so the failure is a Codefly error rather than anE2BIGfrom the kernel. A carrier marked for file delivery that reaches a path unable to deliver files is refused rather than silently inlined. The Kubernetes render applies the same check to the ConfigMap and Secret it loads as the container's environment, and refuses a file ConfigMap or Secret above 1 MiB, the API server's object limit. - Read time. A file carrier whose path is relative, is not a regular file,
is empty, exceeds
resources.MaxFileCarrierBytes, or cannot be read is an error, not an absent value: an unreadable credential never reads as an unset one. An empty file is a truncated write or a mount race, since only a value aboveresources.FileCarrierThresholdis delivered by file. A key delivered both inline and by file is refused as two sources for one fact. - Install time. A contract digest that does not cover the contract delivered with it, a route that is not the operation the value names, a policy outside the installation bounds, an unknown field and an earlier schema are each refused before any operation is installed (What a caller verifies).
- Host files are written
0600in a0700directory owned by the running user, and are removed with the process that read them. A container's files are copied into it, owned by its user, and go with the container. - A secret delivered by file is read with the same rule the SDK applies to an
--output-envexport: a secret file accessible by others is refused. Kubernetes secret volumes are mounted withdefaultMode: 0440and the pod'sfsGroup(see Who can read a delivered file), which grants nothing to others. - A secret value never enters a restricted render in plaintext: the restricted
profile carries no secret values at all (
docs/configuration-contract.md), so there is nothing to file-deliver; its secrets reach the workload through the declared secret references. The file Secret exists only in the local-apply profile, which already carries secret values. - Values are never logged. Errors name keys and sizes, never content; a file carrier's path is not a secret, its content may be.
- Size decides the carrier, not the group. Delivering a declared group
(such as
runnable-bindings) by file regardless of size was considered and rejected: it would make core name a group, and a small value gains nothing from a file. Every value above the threshold goes by file whoever declared it, so a group that grows past the threshold later needs no declaration. - Descriptors, and sharing them by digest, were both removed. An earlier
design shared one descriptor set per owner endpoint, referenced by digest, so
that N operations on one endpoint carried one copy instead of N. It made the
group smaller and left the reason for descriptors in place. Calling an owner
with JSON removes that reason, so there is no set to share, no
DESCRIPTOR_SET__key space and no digest to resolve a set by — and the bounded contract, which a caller does need, is small enough to carry whole. - The bounded contract travels in the value, not by reference. A digest-only value would be smaller by a kilobyte and would oblige every caller to fetch a contract from somewhere before it could validate a payload. The digest is there to make drift visible, not to stand in for the contract.
- The audit spelling and the dial target are separate fields. One string serving as both is what refused every REST operation. Keeping the owner's own spelling matters independently: it is what an effect receipt is keyed by.
- Core owns the reader's rules.
DecodePrepared,VerifyPreparedandResolveFileCarriersare the rules; a caller and an SDK call them rather than restating them, so they cannot drift.
Every part fails closed against every other, so the order is one of usefulness, never of safety:
- core (this document, the types, the carriers and their emitters);
sdk-go:LoadEnvironmentVariablesand direct lookups resolve file carriers;- the CLI:
codefly generate runnable-bindingswritesv3; - the caller: reads
v3through core and refuses anything else; - each workspace regenerates its
runnable-bindingsgroup.
A caller built before step 4 reading a v3 value refuses it as an unknown
schema, and one built after it refuses an earlier value the same way. Neither
calls an owner from the wrong bytes.
The emitters run inside the process that starts or renders the workload — a service agent built with core — so a local run and a Kubernetes render deliver by file once that service's agent is built with this core. Until then the agent emits every value inline exactly as before. A prepared binding is far below the threshold either way: file delivery is a general capability for large values, and no longer something runnable bindings depend on.
- A secret delivered through a restricted render's secret reference is
resolved by the cluster, so its size is unknown at render time; it is still
delivered as
secretKeyRefenvironment. A large secret there needs a secret-volume reference, which the restricted contract does not declare yet. - A service that reads the process environment directly, rather than through a
Codefly SDK, must read
CODEFLY__FILE__*itself.