diff --git a/AGENTS.md b/AGENTS.md index 4614101..8e51d40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,14 +3,24 @@ This repository owns the generic Go runtime for **codefly solutions** — modules deployed independently of the host they extend, with no build-time coupling. It owns what every solution needs identically: configuration resolution through the -codefly SDK, obtaining installation-scoped credentials on actual module work from the projected -service-account token, a plain-HTTP listener on the port Codefly resolves — -the cell's mesh carries transport security and destination identity for every -hop inside it — CORS, Module Federation asset serving, the capability -handshake, the manifest served from the one declared frontend file, the -published authority contract, serving the solution's MCP server to agent -clients, and the gateway client a handler uses to read composed modules on the -viewer's behalf. +codefly SDK, a plain-HTTP listener on the port Codefly resolves — the cell's +mesh carries transport security and destination identity for every hop inside +it — CORS, Module Federation asset serving, the capability handshake, the +manifest served from the one declared frontend file, the published contract, +the connection to the **authority interface** (who each request is for, and +every Work Context the solution acts under), the passthrough that relays a +page's module calls as the viewer, and serving the solution's MCP server to +agent clients. + +**The runtime runs only under the authority.** The saas-host mode — a bearer +forwarded to the host's gateway, `ForModule` minting a Task Work Context +through accounts, the broker execution credential, `Transcoded`/`Unary` +calls over a module's REST binding, the install step, gateway-stamped +identity headers and the MCP surface's OAuth discovery against the host's +issuer — is **deleted, not fenced**. A boot whose composition names no +`authority/endpoint` is refused. If you are about to add a path that reaches a +module, or learns who a viewer is, without the authority, you are rebuilding +what this repository deleted. **A runtime does not register itself.** Presence is delivered — a signed presence document declares which solution runs on which host, and the host @@ -23,32 +33,26 @@ intended outcome. Health is answered, never pushed. If you are about to add something that tells the host this process exists, you are rebuilding what this runtime removed. -It owns none of the counterparts it talks to. The mint endpoint and the -admission rules belong to the host (the composition's own host module) and the -gateway; the authority reader and the one transport rule that admits a -plaintext URL (`workcontext.AdmitEndpoint`) to `codefly-dev/sdk-go`; the broker -wire contract and client to `saas-sdk-go/moduleauthority`; endpoint, port and secret resolution to `sdk-go` as well; +It owns none of the counterparts it talks to. Who a Work Context is for, what +a route requires and every issue belong to the authority +(`codefly-dev/authority`, whose client this runtime links); the one transport +rule that admits a plaintext URL (`workcontext.AdmitEndpoint`) and endpoint, +port and secret resolution to `codefly-dev/sdk-go`; workspace, manifest and presence/authority document types to `codefly-dev/core`; running a composition, provisioning its secrets and rendering the authority document to `codefly-dev/cli`. Everything here is a library — one Go package at the repository root, consumed as the Go module -`github.com/codefly-dev/solution-runtime-go` at a `vX.Y.Z` tag, plus -`passthroughtest`, the test seam a consumer runs the root package's passthrough -under against a fake host. It holds no runtime behaviour of its own: it builds -on the handler `Serve` mounts, reached through `internal/seam`. +`github.com/codefly-dev/solution-runtime-go` at a `vX.Y.Z` tag. -**That seam is not public API, and must not become one again.** +**No exported path hands out the passthrough's handler.** `Server.PassthroughHandler` was exported, and a deployment calling it completed a viewer mint and a module call over plaintext, 200, having skipped -`validate()`, the boot, the ceiling and the origin-pinned outbound client — -with the viewer's bearer and this workload's own credential sent wherever it -was mounted, and supplying a credential source defeated the "no -source, nothing to mint with" mitigation it relied on. Go's internal-package -rule is the gate, which is a compiler rather than a naming convention; -`TestNoExportedPathBuildsACredentialBearingHandlerWithoutTheBoot` fails if an -exported path reappears. +`validate()` and the boot. The +passthrough is mounted by `serve` alone, under the authority. The saas-host +test seam that replaced the export (`passthroughtest`, through `internal/seam`) +went with the saas passthrough it faked. -Beside it, the handler-export, capability-decoder and seal-construction gates +The handler-export, capability-decoder and seal-construction gates are **built on `go/types`**, in `typed_boundary_test.go`, and the fixtures that drive them are **compiled** into this package through a `packages.Load` overlay. Two rules follow from resolution that no list could express: @@ -84,10 +88,10 @@ first run flagging real code: - **What a value HANDS BACK is not what its memory HOLDS.** Construction follows struct fields and array elements — inline memory — and nothing else: a method result, and what a pointer, slice, map or channel refers to, are not - allocated by allocating the value. Expanding every carrier reported - `&authorityHeldSource{…}` as constructing a capability, because its - `CredentialSource` field is an interface whose method returns the SDK's - credential, which is an alias for one of core's wire messages. A **decode + allocated by allocating the value. Expanding every carrier reported a holder + of a credential source as constructing a capability, because its field is + an interface whose method returns the SDK's credential, which is an alias + for one of core's wire messages. A **decode destination** does reach through indirection, because a decoder allocates through it. - **A foreign package's type is its own API.** What this boundary forbids is @@ -137,19 +141,7 @@ suite fail?", and a gate only fails when the counterexample it exists to refuse is in the tree — so its acceptance criterion is the compiled fixture set, driving the rule itself rather than a copy of it. A copy is not evidence: the signing-relaxation test checked its own copy of the condition and survived the -real condition being removed. The same applies to the seam's refusal: the -defeat check (`recover()`, a branch that cannot be taken) ran only where -`mustBeATest()` was called directly, so the identical defeat one hop away was -*inherited* as a refusal and passed. - -`passthroughtest` is itself importable by any module, so that rule alone did not -close this: `passthroughtest.Handler` was an exported, production-importable -path to the same handler, and the gate skipped the package by path while -matching only receiver methods named `*Passthrough*`. Its constructors now panic -outside a test binary (`testing.Testing()`), and the gate reads every caller of -the seam in the module and requires that refusal. A `testing.TB` parameter is -not a gate: the unexported method stops a type declaring the interface, not one -embedding it. +real condition being removed. ## Boundaries @@ -198,27 +190,21 @@ embedding it. that beat an earlier version. - Resolving a value in `loadConfig` is half the rule; the other half is a refusal in `validate()` that fires **whether or not the feature using it is - switched on**. The `api.consumes` projection was resolved at boot and decoded - only by the code that consumes it, which returns early for a solution - declaring no `Consumes` — so one undecodable projection had two answers: - refused for a solution with a passthrough, accepted and served for one - without. It is decoded now in `loadConfig` (`parseAPIConsumes`) and refused by - `validate()` either way, naming the variable it arrived in, since it is the - composition's output and no override repairs it. `projection_boundary_test.go` - pins that, including that unset and whitespace mean "consumes nothing" rather - than "malformed". + switched on**, and a value nothing consumes is not read at all. The + `api.consumes` projection was decoded and judged here only for the saas + passthrough's agreement check; the authority's relay reads its address and + binding from the slots each module's declaration names, so the projection is + no longer read. - A solution claims no module's facade route and holds no credential that would let it: a route for a module is claimed by the module that serves it, under a - credential bound to that module (#51, SA-F-GWREGISTRY). The passthrough and - the gateway client *read* a consumed module at `/v1//*`; nothing here - decides that the prefix points there. `facade_claim_test.go` evaluates the + credential bound to that module (#51, SA-F-GWREGISTRY). `facade_claim_test.go` evaluates the package's constant string expressions and fails on any that is one of those wire shapes — folded, so a shape split across a concatenation, parenthesised, built from named constants or cased differently is the same shape to it. - **Never sign, parse or verify a Work Context here.** There is one - implementation, Core's; this runtime obtains a credential through the native broker - client and delegates its structural read to Core, carries it as the string it travels as, and lets the far end - decide. It declares none of a capability's fields and holds no verifier — + implementation, Core's; this runtime has the authority issue every Work + Context it acts under, carries it as the string it travels as, and lets the + far end decide. It declares none of a capability's fields and holds no verifier — core's needs the issuer's live revision, replay, grant and seal sources, which a solution does not have. `work_context_boundary_test.go` fails on a signing primitive, on a `WorkContext`-named declaration of this package's own, on a @@ -231,39 +217,30 @@ embedding it. core's `WorkSealV1`, so the one SDK import this package already has is enough to allocate one and decode into it. The rule is on allocation and on decode DESTINATIONS rather than on the type appearing, because the type appears - legitimately: `holdSealedIdentity` takes a `*SealedValues` and reads it - through `GetInstallationId`. It keys on core's generated PACKAGE PATH rather + legitimately: a seal read through the SDK's `GetInstallationId` accessor is + not an allocation. It keys on core's generated PACKAGE PATH rather than a set of message names, so an alias — the SDK's `Claims`, a function-local one, anything — resolves to the same type and a message core adds is covered the day it exists. This is not a style rule: a second signed encoding grew inside an SDK beside core's once, with its own payload struct, signer, verifier and error taxonomy, and 3,532 lines had to be deleted to get back to one. Every step of that was locally reasonable. -- The authority-bearing values are a narrower case still: the principal, the - mint audience and the projected token's own audience are read **once**, through - the SDK's authority reader, and frozen. The credential this process holds is - sealed to the values it was minted under, so a value that resolves differently - later is an error and never a reload — the reader rechecks them before every - renewal, which is the one moment a drifted value would otherwise be laundered - into a credential nobody approved. Do not add an ordinary accessor for one of - those three. - Most values also take an explicit env override, for an operator whose - deployment the resolver cannot see — but an override is not how a - *composition* supplies a value, because a Codefly render cannot set a bare - environment variable on a service. Anything the composition has to supply is - a declared workspace-configuration group read with `WorkspaceConfiguration` / - `WorkspaceSecret` (`mcp/issuer-url`, `mcp/public-url`), and a refusal names - that group/key, never a variable name. +- Anything the composition has to supply is a declared workspace-configuration + value read with `WorkspaceConfiguration` (`authority/endpoint`, + `hop/ENFORCEMENT`, each consumed module's relay slots), and a refusal names + that group/key, never a variable name: a Codefly render cannot set a bare + environment variable on a service. The env overrides that remain (`PORT`, + `CODEFLY__WORKLOAD_TOKEN_FILE`, `CODEFLY__CONTRACT_PROFILE`, `ASSETS_DIR`) + are for an operator whose deployment the resolver cannot see. - Validate at the boundary — boot, and the headers a request arrives with — not between internal callers. `validate()` is the model: each refusal names the variable or the provisioning path that fixes it. - **The listener is plain HTTP on the resolved port, and the cell's mesh is - what carries the hop.** Past the host's auth gateway every hop inside a cell + what carries the hop.** Past the authority's edge every hop inside a cell is plaintext under istio ambient's ztunnel mTLS, the workload's identity is its ServiceAccount's SPIFFE ID — the mesh principal a presence document names, not an SVID this process presents — and TLS ends at the edge. So this - listener presents no certificate and verifies no caller, and the mesh - admits its port only from the gateway's principal. The previous generation + listener presents no certificate and verifies no caller. The previous generation served TLS 1.3 under a projected X.509-SVID, verified callers against a projected anchor, admitted them by three provisioned peer files, re-verified every established connection once a second in both directions and refused @@ -272,185 +249,42 @@ embedding it. cell decides for every workload. If you are about to add a check on who is on the other end of a connection, you are rebuilding what this cutover removed; what this process still decides is what a caller may do once - admitted (the stamped identity headers, this execution's credential) and - where its own credentials may travel (below). + admitted (the authority's Check of the request's Work Context) and where the + viewer's Work Context may travel (below). - **A plaintext destination is admitted only where the mesh is, by the SDK's - rule and never by a copy of it.** The gateway URL — and the mint derived on - it — goes through `workcontext.AdmitEndpoint`: `https` to any host; `http` - to a loopback host on a local run, or to an in-cluster Service address - (`..svc[.]`) when the composition asserts - `internal-transport/mesh-protected=true`; anything else is `ErrInvalid` - naming the rule. It is the rule the composed modules apply to their own - gateway value, and `validate()` and `newBrokerCredentialSource` both call - it rather than restating it. The assertion is read ONCE, in `loadConfig`, - into `meshTransport`, and answered from that snapshot wherever the rule - asks — because every environment read sits in `loadConfig`'s call tree, and - a configuration value is fixed at process start anyway. The snapshot keeps - the SDK's three answers three (unset, set to a value, unreadable) and never - trims the value: only the literal `true` asserts, and a misspelled - assertion is a refusal rather than either answer. Do not add a boolean - beside it; a per-service opt-in is what an address never has to qualify for. -- Workload credentials are acquired only for actual authority-consuming work, - after the declared ceiling is checked. Boot, health, viewer-only routes, MCP - handshakes and installation acknowledgements never mint. Authority-free - declarations need no broker credentials. Each organisation/installation has - independent acquisition, expiry and refusal state; no global credential. -- **Credential-bearing traffic never follows a redirect and never leaves the - gateway's origin.** Go copies a request's headers onto a redirected one and - strips only `Authorization`, `WWW-Authenticate` and `Cookie`, a 307 re-sends - the body, and this runtime sets the bearer per round trip — so a client that - merely inherits a transport, as every gateway client once did, hands a `307 - Location: http://…` the viewer's bearer, their capability and this workload's - credential in cleartext. Build clients through `Gateway.platformClient`, which - carries `ErrUseLastResponse`, and leave the transport's origin pin in place. -- **Authentication is the mesh's; what is still this process's is which - stamped identity a request carries.** `x-org-id`, `x-session-id` and - `x-codefly-installation-id` are read as the gateway stamped them from - verified claims, and a request reaching this port came through the gateway - or the mesh would not have carried it. The admitted-caller file this - runtime once read per handshake, and the two per-destination peer files for - the mint and the gateway, are gone: `ALLOWED_CALLERS_FILE`, - `MINT_PEERS_FILE` and `GATEWAY_PEERS_FILE` admitted identities the mesh now - admits at L4 from the edges the CLI delivers, and a second set here would be - a second answer. The runtime dials exactly one origin, the gateway's - workload endpoint, and `originPinned` refuses every other destination. -- **Every mint names the installation it acts under, and it comes from the - header the gateway stamps.** The host requires it (core's - `StartInput.InstallationID`) and refuses a mint naming none; an organization - is not an installation, since one org may hold several. - `x-codefly-installation-id` is the source, on the same footing as `x-org-id` - and `x-session-id`: the gateway stamps all three from verified claims and - replaces whatever a caller sent. A request naming none is refused, and not as - a `ClientError` — no caller can stamp one, so it is not theirs to fix. - **This paragraph said the opposite for two rounds.** It described reading the - installation from the *seal* of the carried capability, with the header as a - fallback, which is the SDK's rule for the direction where a capability IS the - authority being carried — outbound, where `Attach` puts the seal's own - installation beside it and the far end cross-checks the two. Inbound that - carrier is caller-controlled: a browser can put a capability in - `x-codefly-work-context`, and this runtime's standing property — with its own - test, `TestBrowserSuppliedWorkContextIsNeverForwarded` — is that such a - capability authenticates nothing. Reading the installation out of it would - let a caller name the deployment their own mint is attributed to, and because - `FromHeaders` refuses a capability whose installation carriers are - incomplete, a caller sending a malformed one would decide whether the mint - happens at all. The code has read only the stamped header since round ten; - these two documents did not follow it, which is the failure this file warns - about in its own first line. - A background or delegated call would take the installation from the parent - capability's seal on the held credential; no such path exists here, because - every mint is viewer-driven and `ForModule` refuses a caller without an org - and a session. -- **Credentials remain scoped to their organisation and installation.** The - broker selects live consent from the organisation request; its response must - match both host-stamped identifiers. Renewals within a scope retain its - installation, digest and build incarnation. Across scopes and scope eviction, - the process retains the same digest and incarnation. A mismatch refuses that - work; it never adopts another execution or shuts down unrelated installations. -- Every `ForModule` path, including HTTP and MCP, uses `gatewayFor` to carry - the declared ceiling and the host-stamped organisation/installation into - acquisition. Plain route entry is not a request for module authority. Keep - incoming identity validation and error sanitization at their boundaries. -- **What a composition supplies is declared configuration, and a published URL - is held to what a dialled one is.** A Codefly render cannot set a bare - environment variable on a service, so anything the composition owns is a - workspace-configuration group read through the SDK (`mcp/issuer-url`, - `mcp/public-url`) and a refusal names that group/key. In a deployed runtime - context the MCP resource identifier must be **declared, not derived**: the - derivation builds `/solutions//mcp`, which is this runtime - encoding the route the *host* serves it on — the assumption the Module - Federation manifest URL was changed to stop making, for the same reason. A - derived value satisfied the older "a public URL is set" check, so nothing - refused it in the one case that mattered. Both MCP URLs are refused for - userinfo, a query, a fragment, and plaintext in a deployment, and those - refusals redact: the pairing refusal printed an identifier's `?token=` into - the boot log. -- **Nothing mints per request.** One credential per organisation/installation plus renewals - its own expiry dictates, and that has to survive the checks added in front of - it: a route gate that asked the source on every viewer request turned a - 503-ing issuer into one audited mint per request, which is worse than the - heartbeat this runtime deleted, because the heartbeat at least minted on a - timer. Acquisition is single-flight with a backoff, a credential already in - hand and unexpired serves while the issuer is unavailable, and a terminal - answer is recorded **where the answer lands** rather than by whichever caller - was still waiting — the ask is detached, so it can finish with nobody - listening. -- **Nothing is reported with the mint, and the frontend is declared in one - file.** `upstream` (the advertised origin) and `manifest` (the frontend - manifest) travelled in the mint body for one generation and are deleted: - the gateway resolves this solution's address from the delivered presence - document's endpoints, through the SDK, from the carrier the CLI hands it — - as for a module — and the frontend travels in the same document. The CLI - reads it from `solution.frontend.codefly.yaml` beside the backend's + rule and never by a copy of it.** Each relayed module's hop address goes + through `workcontext.AdmitEndpoint` when its relay is opened: `https` to any + host; `http` to a loopback host on a local run, or to an in-cluster Service + address (`..svc[.]`) when the composition asserts + `internal-transport/mesh-protected=true`; anything else refuses the boot. + The assertion is read ONCE, in `loadConfig`, into `meshTransport`, and + answered from that snapshot wherever the rule asks. The snapshot keeps the + SDK's three answers three (unset, set to a value, unreadable) and never trims + the value: only the literal `true` asserts. Do not add a boolean beside it. +- **Every Work Context is issued by the authority, and none is minted here.** + A handler's gateway acts for whom the authority's `Check` says the request's + context is for, naming the route so the solution's delivered policy applies; + `ForParent` asks for a child of it through one of the solution's bindings, + `ForBinding` for the solution's own through a binding. An issued context is + cached for the request under the ask, single-flight, with the scopes + deep-copied at the ask, and renewed before its expiry; a 401/403 refusal is + held for the request rather than re-asked. +- **Nothing is reported to the host, and the frontend is declared in one + file.** The CLI reads `solution.frontend.codefly.yaml` beside the backend's `service.codefly.yaml`, the same file this runtime embeds (`Manifest.Frontend`, `go:embed`) and serves its manifest from (`frontend.go`): two readers of one file cannot disagree, where a Go field - beside a file could. Its keys are core's `frontend` block field for field, - every scalar declared because core refuses an omitted one, the dashboard - held to the JSON object core carries (a mapping, string keys, no fraction, - 64 KiB encoded) and surfaces carried whole (`contract`, `applies`, `events` - included). A boot without the file is refused naming it. Do not add a Go - field for anything the host lists a solution under, and do not report - anything about this process to the host: a process cannot declare what the - composition did not. -- The install endpoint acts under the host's incoming installation capability. - Its acknowledgement never acquires workload authority. HTTP 412 means a - failed precondition, not a unique "installed nowhere" state. Later actual - work may retry after bounded backoff; no startup/idle/install polling loop. -- **The execution mint follows the host's module broker contract natively, - over plain HTTP to the gateway's workload endpoint.** - `saas-sdk-go/moduleauthority.MintModuleWorkContext` owns the broker wire - protocol. Supply `prefix: Manifest.ID` and the host-stamped organisation to - `/modules/_work-context`, with - the projected token as bearer and `X-Codefly-Internal-Token` from the - declared workspace secret `module-authority/INTERNAL_TOKEN` — kept: the - host's waiver of that header on its workload peer existed only because the - SVID authenticated the peer, and with the SVID gone the waiver goes too. - Resolve the secret in `loadConfig`, refuse absence in `validate()`, and - never resurrect a legacy secret reader. The projection audience is - `accounts`; the prefix never replaces the frozen SPIFFE principal. The mint - URL is the host's published route on the gateway this process already - resolved: the workload endpoint serves the gateway's whole handler, so there - is no second address to resolve and `CODEFLY__CREDENTIAL_MINT_URL` is gone. - The native transport owns one plain-HTTP request per mint on a connection - dialled for it, pinned to that origin, with redirects, proxies and reuse - disabled, and the URL held to the SDK's transport rule before anything is - sent; an `https` gateway is verified against the system roots, the right - anchor for an edge that presents a public certificate. The SDK still owns - the authority reader. `workcontext.MintClient` is NOT the production mint - client and must not become one without the host serving its wire shape: it - posts `{audience, projection_audience}` and reads `{work_context}`, where - the host takes `{prefix, organization_id}` and answers `{token, expiresAt, principalId, - tenant}`; this runtime replaced it with the native client in #59 for that - reason, and the two places that still construct one — `credential_test.go` - and `passthroughtest` — do so against fakes that speak the SDK's shape, which - is a finding for the audit and not something a deployment exercises. Core's - `Decode` is the only structural reader of the returned token; no signed - message is constructed or decoded here, and that read verifies no signature. - It was `Inspect` until core v0.15, and the old name is now core's - `Inspector` — a forwarding hop's **signature check** — so it is a verifier to - this package and the verifier gate refuses it, with `Authenticator`, by name. - A private credential view keeps the public SDK `CredentialSource` compatible. -- Refusals are scoped. Each controller retains the answer even if all waiters - leave, backs off, and never uses held authority after refusal or expiry. - Later work may recreate a refused scope after backoff to observe changed - consent. A failure never stops other installations or the shared listener. - Keep safe status/class/category, never bodies, parser messages or causes. - Missing projection, 412, 503 and transient transport failures are unavailable; - frozen authority and execution consistency still fail closed on every ask. -- Bound retained scopes (256), waiters (cancellation/500ms), acquisition lifetime, - and detached projection reads. Never evict a scope with active work/acquisition. - The server shares the projection read slot across scopes so eviction cannot - accumulate blocked file reads. Do not let one caller cancel shared acquisition. -- A module's 409 does not prove workload revocation. The host must provide a - machine-readable discriminator before the runtime may infer supersession. -- **Fail closed, with no exception for a counterpart's current state.** A mint - this runtime cannot attest for is not sent; a credential-bearing destination - outside the SDK's admitted transport rule is refused at boot. "The gateway presents no client - certificate today" and "the host does not require the attestation yet" are - statements about someone else's deployment, and leaving a hole open for as long - as the counterpart takes is the concession this cutover exists to stop making. -- The published contract is **declared, not derived**. A scope ceiling computed + beside a file could. Do not add a Go field for anything the host lists a + solution under, and do not report anything about this process to the host: + a process cannot declare what the composition did not. +- **Fail closed, with no exception for a counterpart's current state.** A + request whose Work Context the authority does not check is refused before a + handler or a tool runs; a module address outside the SDK's admitted + transport rule is refused at boot. A statement about someone else's + deployment is not a reason to leave a hole open for as long as the + counterpart takes. +- The published contract, when a solution declares one, is **declared, not + derived**. A scope ceiling computed from what the code asks for would be satisfied by construction — a method that asked for one more action would widen the ceiling meant to refuse it — so the author declares the ceiling per profile, the boot refuses a declaration that @@ -471,13 +305,13 @@ embedding it. `TestThisRuntimesContractDoesNotClaimTheRenderersSchema` fails if the two ever converge again. Writing the renderer's file would be a generator in this repo and nothing here does it today. -- **The MCP surface verifies no token, and must not start.** The gateway runs - ext_authz and stamps the identity, so a check here would be a second, - divergent copy of the host's admission rules. What `requireStampedViewer` - enforces is that the gateway did it, and the refusal for a credential with no - session is at that boundary rather than inside a tool, where it would read as - a broken tool. A tool's arguments are written by a model: no identity ever - comes from them. +- **The MCP surface verifies no token, and must not start.** It is the + transport only — the SDK's stateless Streamable HTTP handler at + `/v1//mcp` and the middleware that binds each call to its viewer — and a + call is admitted exactly as a handler route is, by the authority's `Check` + naming the route. A tool's arguments are written by a model: no identity + ever comes from them. How an agent client obtains a Work Context for a + solution is the authority's to answer, not a second admission rule here. - Tests live beside the code in the same package. Every behaviour change brings one, and the counterpart it exercises is an `httptest` server, never a real host. @@ -516,28 +350,11 @@ against. to core, the SDK or the host. 4. **Never hardcode what the system resolves, and never guess what it cannot.** This package resolves every address, port and secret through the SDK, and - carries no address literal at all: the `localhost` the advertised-origin - fallback used went with the report it fed. The credential mint URL is the - one derived value, and it is derived from a published fact rather than a - guess: the gateway's workload endpoint serves the gateway's whole handler, - mint included, so the mint is `/modules/_work-context` on the one origin the - composition resolved. That is a different thing from the derivation this - rule used to forbid — a path assumed on the public gateway while the host - served its mint somewhere nobody had published, carried for two rounds as a - "labelled stopgap" — and the test that pinned its absence - (`TestNoProductionCodeDerivesTheMintAddress`) was replaced by one that pins - the published route. If the host moves the mint off the workload endpoint, - the fix is a resolved endpoint role, not an override. + carries no address literal at all. If you are typing an address, a port or a credential, you are encoding something true only on your machine for the next ten minutes; if you are deriving one the owner has not published, you are encoding a guess about somebody else's deployment. - The MCP resource identifier is derived the same way — from the resolved - `PUBLIC_URL` and the solution id — and is the one place the gateway's - `/solutions/` route is encoded here. `PUBLIC_URL` itself went with the - manifest registration it fed and came back for this one consumer: whether a - runtime should derive the host's route at all is an open follow-up, not a - settled answer. 5. **Diagnose, do not pattern-match.** "It started working when I set X" is not a diagnosis — set X back and confirm it breaks. Do not trust an error message before checking it: in the session above, *"the provisioned secret does not @@ -548,11 +365,10 @@ against. inspect endpoint resolution over a variable they had broken themselves. Keep new refusals that specific. 6. **Say what you did not verify.** Unverified is not the same as working, and - this suite makes that easy to get wrong: it is hermetic. Every host, gateway - and accounts counterpart is an `httptest` server on `127.0.0.1`, so a green - run proves the wire shapes and refusals this package produces — never that a - real host admits a registration, that accounts mints a Work Context, or that - a composed module accepts one. If you did not run it against a real + this suite makes that easy to get wrong: it is hermetic. The authority and + every relayed module are stand-in servers on `127.0.0.1`, so a green run + proves the wire shapes and refusals this package produces — never that a + real authority issues a Work Context or that a composed module accepts one. If you did not run it against a real composition, the PR says so. 7. **Prove reuse before you build.** The default answer is that the platform @@ -588,13 +404,10 @@ test -z "$(gofmt -l .)" Go comes from `go.mod` (1.27). Nothing else is needed: no Docker, no credentials, no running composition. -- The suite takes about a minute under `-race` and is hermetic: the host's mint, its gateway - and accounts are all `httptest` servers on `127.0.0.1`, the workload identity - is a key pair the test generates into a temp directory, and a stand-in - capability is minted by **core's** authority from core's published fixture - identities — never by a signer of this repo's own. The two - heartbeat tests that waited on 10s and 5s of real wall time are gone with the - heartbeat. +- The suite takes about a minute under `-race` and is hermetic: the authority + is a gRPC stand-in on `127.0.0.1` answering `Check` and `Issue`, each relayed + module a gRPC stand-in behind it, and the projected workload credential a + file the test writes into a temp directory. - The formatting gate runs over the whole tree (`gofmt -l .`), so a package added in a subdirectory is format-checked as well as vetted and tested. - Dependency pins are how fixes from `core` and `sdk-go` reach consumers: @@ -612,14 +425,13 @@ credentials, no running composition. Keep this file short; add depth to the file that owns the subject. - [README.md](README.md) — the consumer-facing contract: handler errors, the - full configuration and env-override table, plain HTTP inside the cell, the - declared frontend file and the one mint, the published contract, - consumed-module federation, and reading a - Work-Context-authenticated module. It also carries the migration note: an old - runtime against a new host gets 404, and nothing bridges the two. + full configuration and env-override table, the authority interface, plain + HTTP inside the cell, the declared frontend file, the published contract, + the passthrough and the MCP surface. It also carries the migration note: the + saas-host mode is deleted, and nothing bridges the two. - The package doc comment and the comments in [`solution.go`](solution.go), - [`credential.go`](credential.go), [`frontend.go`](frontend.go) and - [`contract.go`](contract.go) and [`mcp.go`](mcp.go) — why a refusal, a renewal point or a cache + [`authority.go`](authority.go), [`passthrough.go`](passthrough.go), + [`frontend.go`](frontend.go), [`contract.go`](contract.go) and [`mcp.go`](mcp.go) — why a refusal, a renewal point or a cache lifetime is what it is. Several record a failure mode that is not obvious from the code; read the one next to what you are changing before you change it. - `.claude/skills/` — procedures an agent repeats, loaded only when relevant. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e9ad53..b2054f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,184 @@ # Changelog +## Unreleased — the operation checked is the operation run; every served route publishes its policy (breaking) + +From the independent review of 2026-10-25 (F1–F4, F10, F11): + +- **Dispatch by method and path (F1, security).** The runtime picked a + handler by path alone, so with a GET (read policy) and a POST (write policy) + on one path, a GET checked against the read row ran the POST handler. A + request is now answered by the operation declared for its method on its + path, which is the operation the authority's `Check` names; a method the + path does not declare is answered 405, naming the methods it serves (and + `Allow`), before the authority is asked or any handler runs. The boot + refuses an operation that declares no policy, is undocumented, declares one + method twice on a path, or names a method that is not an operation's. + `Server.Handle` and `Server.HandleRequest`, which registered a route with + no method and no policy, are deleted: `Operations` is the one way a backend + serves a route of its own. +- **Issues go through the authority's hop library (F2).** `ForParent` and + `ForBinding` ask through `hop.Outbound.IssueForParent` / `IssueForModule` + (codefly-dev/authority `libraries/hop/go`, extended for it) with + `hop.FileCredential` at the projected path; the runtime builds no + `IssueRequest` and reads no credential file of its own. +- **The MCP route publishes its policy (F3).** `ServeMCP(name, version, + policy, register)` takes the policy a call to `POST /v1//mcp` must hold, + and the interface artifact publishes it there, so the CLI's rows include the + route and an enforcing authority admits it. Only POST is served. +- **Relayed routes are any checked caller's (F4).** Every relayed + `/v1//modules//...` route is published with + `{"any_checked_caller": true}` and the passthrough's `Check` names it as + served; the gate on a relayed call is the per-call child issued through the + solution's binding (the person's current grants ∩ the binding) and the + module's own method policy at its hop. The README says why. A route refusal + reaches the page as `permission_denied`. +- **`InterfaceArtifact` is the server's (breaking).** `Server.InterfaceArtifact(dir, + info)` renders the artifact from what the server serves — its operations, + its MCP route and the passthrough at the paths they are served at — so a + route cannot be served without a row. The free `InterfaceArtifact(dir, info, + ops, modules...)` is deleted. +- **An authority outage is named (F10).** A `Check` the authority did not + answer is 503 `ErrAuthorityUnavailable` (Connect `unavailable`), never a + 401 that reads as "sign in". +- **The authority endpoint passes the SDK's credential-destination rule + (F11).** It is dialled through the same `workcontext.AdmitEndpoint` rule as + every relayed module. + +## Unreleased — the runtime runs only under the authority; the saas-host mode is deleted (breaking) + +Since the passthrough relays only through the authority, nothing a consuming +solution does reaches the saas host any more, and the rest of that mode is +deleted with its tests and docs: + +- `Gateway.ForModule` and its published-ceiling check, the accounts + `StartTask` mint, and the identity headers it was rooted in + (`Gateway.InstallationID`, `x-org-id`, `x-session-id`, + `x-codefly-installation-id`). +- The broker execution credential: `Server.Credential`, `CredentialSource`, + the `module-authority` group (`AuthorityGroup` and its keys, + `INTERNAL_TOKEN`), the per-installation credential controller, and the + `saas-sdk-go` dependency. +- Calls through the saas gateway: `Gateway.Transcoded`, `MaxResponseBytes`, + `TranscodeOption`, `DefaultTranscodedResponseLimit` (now + `DefaultPassthroughResponseLimit`), `Unary`, `Gateway.HTTPClient`, + `Gateway.BaseURL`, gateway resolution by role (`GATEWAY_URL`, + `CODEFLY_HOST_MODULE`, `CODEFLY_HOST_GATEWAY`, `CODEFLY_HOST_FRONTEND`). +- The install step: `Server.OnInstall`, `Installation`, `RefuseInstall`, + `ErrInstallRefused` and `POST /_codefly/installations`. +- The MCP surface's saas wiring: the gateway-stamped identity it read, the + OAuth Protected Resource Metadata document and its challenge, + `ProtectedResourceMetadataPath`, `MCPEnvironment`, the `mcp` group + (`issuer-url`, `public-url`) and `PUBLIC_URL`. +- `ErrNotAttested`, `ErrCredentialRefused`, `ErrCredentialUnavailable`, + `WorkContextPrincipals` from an accounts mint, and the published + contract's `principal`. + +Kept, because the authority path reaches it: + +- `ServeMCP` and `ViewerFromContext`: the MCP transport, now served at + `/v1//mcp` and admitted exactly as a handler route is — the authority's + `Check` of the Work Context the request carries, naming `POST /v1//mcp`. + A tool reaches a module through `ForParent`. This is the transport an + authority-based MCP route (robin calling a solution's tools) builds on. +- `Contract`/`ModuleContract`/`ContractArtifact`: optional; a solution that + declares no ceiling is bounded by the authority alone. +- `GatewayError`, `ClientError`, `WorkContextRefusal`: a handler's own + refusals and the authority's. + +A composition that names no `authority/endpoint` is now refused at boot, +whether or not the solution consumes modules, and so is one with no projected +workload credential path. + +## Unreleased — the passthrough relays only through the authority, by each module's own procedure (breaking) + +The passthrough relays every declared method over gRPC to the module's own +procedure — the Connect/gRPC face the module serves behind the authority's +hop — so a module whose protos carry no `google.api.http` binding is consumed +as it is. No binding is read: the boot no longer requires one under +`/v1/` for every declared method, which refused every solution pinned to a +module that serves only behind the hop. A server-streaming method is relayed +as a gRPC stream, bounded and masked as before. + +The saas-host passthrough is deleted, with its configuration, tests and docs: +forwarding a method over its REST binding through the saas gateway (`forward`, +the NDJSON stream reader and `Gateway.TranscodedStream`, `StreamError`, +`ErrStreamMessageTooLarge`, `StreamOption`), `ConsumedModule.ViewerBearer`, +the check of the declaration against the `CODEFLY__API_CONSUMES` projection +(which is no longer read), the plain-text gateway refusal relay, and the test +seam that faked the saas host (`passthroughtest`, `internal/seam`). + +- `ConsumedModule.Authority` is required on every consumed module, naming all + three of its group, endpoint slot and binding slot. +- A solution that declares consumed modules and has no `authority/endpoint` + refuses to boot, naming it — before the gateway, which its page's calls + never reach. The `module-authority/INTERNAL_TOKEN` refusal it replaces is + gone, and so is the boot's broker credential for a consuming solution in + saas mode, which this made unreachable. +- `GatewayError` keeps only its status: the refusal body it carried was read + by the saas passthrough alone. + +## Unreleased — every documented operation declares its authority policy (breaking) + +`Operation.Policy` is what a call to the route must hold — +`Requires(kind, actions...)` or `AnyCheckedCaller()`, the shape of the +authority's `authority.hop.v1.Policy` — and `InterfaceDocument` writes it as +the operation's `x-codefly-authority-policy`, the extension the CLI derives +the solution's `"