diff --git a/apps/api/src/routes/mcp/deployment-guide-tools.ts b/apps/api/src/routes/mcp/deployment-guide-tools.ts index 15b7217d8..c77a330f9 100644 --- a/apps/api/src/routes/mcp/deployment-guide-tools.ts +++ b/apps/api/src/routes/mcp/deployment-guide-tools.ts @@ -80,7 +80,7 @@ In your Compose file, reference these as normal interpolation placeholders, for ### Step 3 — Author the Compose stack -Write a standard Docker Compose YAML file in the workspace. SAM supports multi-service stacks and derives public routes from either \`x-sam-routes\` or each service's \`ports:\`. +Write a standard Docker Compose YAML file in the workspace. SAM supports multi-service stacks and derives public routes from each service's \`ports:\` by default. Use \`x-sam-routes\` only when you need an explicit override, such as exposing a service port without \`ports:\` or marking a matching \`ports:\` entry as \`private\`. \`\`\`yaml services: @@ -94,11 +94,13 @@ services: interval: 30s timeout: 5s retries: 3 + ports: + - "3000" x-sam-routes: - service: web port: 3000 - mode: public + mode: private \`\`\` Constraints for compose-publish releases: @@ -150,7 +152,7 @@ After the publish job reaches \`succeeded\`: 1. \`list_deployment_environments()\` → pick the target. 2. \`list_deployment_environment_config(environment)\` → review; \`set_deployment_environment_config(...)\` for anything missing (Secrets via \`isSecret: true\`). -3. Author the Compose stack with \`\${VAR}\` placeholders and \`x-sam-routes\`. +3. Author the Compose stack with \`\${VAR}\` placeholders and \`ports:\` for public app routes; add \`x-sam-routes\` only for explicit overrides. 4. \`build_and_publish(environment)\` → capture \`publishJobId\`. 5. \`get_publish_status(publishJobId)\` until terminal. 6. \`read_deployment_logs(environment)\` and \`check_dns_status()\` → verify it is actually running.`; diff --git a/apps/api/src/services/compose-publish-apply.ts b/apps/api/src/services/compose-publish-apply.ts index 4be34baa8..3947672ed 100644 --- a/apps/api/src/services/compose-publish-apply.ts +++ b/apps/api/src/services/compose-publish-apply.ts @@ -21,10 +21,13 @@ * re-labelled). * - `build:` is replaced with the digest-pinned `image:` that the publish * orchestrator already pushed to the project registry (`pushedRef`). - * - `ports:` is TRANSFORMED (not stripped): each published container port - * becomes a public route (hostname + loopback hostPort) and the service's - * ports are rewritten to `127.0.0.1::` so node-local - * Caddy can reverse-proxy to it. + * - `x-sam-routes` is the explicit route override layer. `ports:` still + * creates public routes by default, but an explicit `mode: private` + * suppresses a matching `ports:` route. + * - Public routes are TRANSFORMED into loopback host bindings: each routed + * container port gets a hostname + loopback hostPort and the service's ports + * are rewritten to `127.0.0.1::` so node-local Caddy + * can reverse-proxy to it. * - Every other denied field (DENIED_SERVICE_FIELDS) is stripped with a * warning. `logging`/`labels` are denied because SAM re-injects its own. * - SAM injects: the per-environment bridge network, sam.* labels, @@ -58,9 +61,9 @@ import { } from './compose-image-artifacts'; import { assignRouteTargets, + collectComposePublishPublicRoutes, type DeploymentRouteTarget, type DeploymentRouteTargetOptions, - type PublicRouteInput, } from './deployment-routing'; export const DEFAULT_COMPOSE_PUBLISH_MEMORY_LIMIT_MB = 256; @@ -381,10 +384,9 @@ export function buildComposePublishApplyPayload( const artifactByService = buildArtifactMap(submission); const networkName = `sam-internal-${opts.environmentId.replace(/[^a-zA-Z0-9_-]/g, '-')}`; - const publicRoutes: PublicRouteInput[] = []; - // Track which service each route maps to, in route order, so we can rewrite - // the service's ports to loopback bindings after host ports are assigned. - const routeServiceByIndex: Array<{ service: string; containerPort: number }> = []; + const publicRoutes = collectComposePublishPublicRoutes(doc, { + portExtractor: collectServiceContainerPorts, + }); let hasModelProvider = false; const artifacts: ComposeImageArtifactDescriptor[] = []; @@ -444,14 +446,9 @@ export function buildComposePublishApplyPayload( } } - // Collect public routes from ports: (transform exception — NOT stripped). - const containerPorts = collectServiceContainerPorts(name, service.ports); - for (const port of containerPorts) { - publicRoutes.push({ service: name, port }); - routeServiceByIndex.push({ service: name, containerPort: port }); - } - // Remove ports for now; rewritten to loopback bindings after host-port - // assignment below. A service with no resolvable ports keeps none. + // Remove ports for now; public routes are rewritten to loopback bindings + // after host-port assignment below. Explicit `mode: private` routes leave + // the matching service port unpublished. delete service.ports; // Strip every other denied service field (WARN, never error). @@ -487,14 +484,11 @@ export function buildComposePublishApplyPayload( // Rewrite each routed service's ports to loopback bindings now that host // ports are assigned. routes preserves publicRoutes order. const loopbackPortsByService = new Map(); - routes.forEach((route, index) => { - const mapped = routeServiceByIndex[index]; - // Defensive: assignRouteTargets preserves order, so mapped.service === route.service. - const serviceName = mapped?.service ?? route.service; - const list = loopbackPortsByService.get(serviceName) ?? []; + for (const route of routes) { + const list = loopbackPortsByService.get(route.service) ?? []; list.push(`127.0.0.1:${route.hostPort}:${route.containerPort}`); - loopbackPortsByService.set(serviceName, list); - }); + loopbackPortsByService.set(route.service, list); + } for (const [serviceName, loopbackPorts] of loopbackPortsByService) { const service = outServices[serviceName]; if (isPlainObject(service)) { diff --git a/apps/api/src/services/deployment-routing.ts b/apps/api/src/services/deployment-routing.ts index 573512197..de24456d4 100644 --- a/apps/api/src/services/deployment-routing.ts +++ b/apps/api/src/services/deployment-routing.ts @@ -107,6 +107,163 @@ export interface PublicRouteInput { port: number; } +type ComposePublishRouteMode = 'public' | 'private'; + +interface ComposePublishRouteInput extends PublicRouteInput { + mode: ComposePublishRouteMode; +} + +interface ComposePublishPublicRouteOptions { + portExtractor?: (serviceName: string, ports: unknown) => number[]; +} + +function routeKey(service: string, port: number): string { + return `${service}\u0000${port}`; +} + +function extractComposePorts(_serviceName: string, ports: unknown): number[] { + if (!Array.isArray(ports)) { + return []; + } + const result: number[] = []; + for (const portSpec of ports) { + const port = extractContainerPort(portSpec); + if (port !== null) { + result.push(port); + } + } + return result; +} + +function formatRouteErrors(errors: Array<{ path: string; message: string }>): string { + return errors.map((err) => `${err.path}: ${err.message}`).join('; '); +} + +function parseComposePublishExplicitRoutes( + xSamRoutes: unknown, + services: Record +): ComposePublishRouteInput[] { + if (xSamRoutes === undefined || xSamRoutes === null) { + return []; + } + + const errors: Array<{ path: string; message: string }> = []; + const routes: ComposePublishRouteInput[] = []; + + if (!Array.isArray(xSamRoutes)) { + throw new Error( + 'Compose-publish route validation failed: x-sam-routes: "x-sam-routes" must be an array of route definitions.' + ); + } + + for (const [index, route] of xSamRoutes.entries()) { + if (!isPlainObject(route)) { + errors.push({ + path: `x-sam-routes[${index}]`, + message: 'Each route must be an object with service, port, and mode fields.', + }); + continue; + } + + const service = route.service; + const port = route.port; + const mode = route.mode ?? 'public'; + + if (typeof service !== 'string' || service.trim() === '') { + errors.push({ + path: `x-sam-routes[${index}].service`, + message: 'Route must specify a "service" name.', + }); + continue; + } + + if (!Number.isInteger(port) || typeof port !== 'number' || port < 1 || port > MAX_TCP_PORT) { + errors.push({ + path: `x-sam-routes[${index}].port`, + message: `Route must specify a valid "port" (1-${MAX_TCP_PORT}).`, + }); + continue; + } + + if (mode !== 'public' && mode !== 'private') { + errors.push({ + path: `x-sam-routes[${index}].mode`, + message: `Route mode must be "public" or "private", got "${String(mode)}".`, + }); + continue; + } + + if (!(service in services)) { + errors.push({ + path: `x-sam-routes[${index}].service`, + message: `Route references service "${service}" which is not defined in "services". Defined services: ${Object.keys(services).join(', ')}`, + }); + continue; + } + + const serviceConfig = services[service]; + if (!isPlainObject(serviceConfig)) { + errors.push({ + path: `x-sam-routes[${index}].service`, + message: `Route references service "${service}" which is not a service mapping and cannot be exposed.`, + }); + continue; + } + + if ('provider' in serviceConfig) { + errors.push({ + path: `x-sam-routes[${index}].service`, + message: `Route references provider service "${service}", but provider services cannot be exposed as SAM app routes.`, + }); + continue; + } + + routes.push({ service, port, mode }); + } + + if (errors.length > 0) { + throw new Error(`Compose-publish route validation failed: ${formatRouteErrors(errors)}`); + } + + return routes; +} + +/** + * Derive compose-publish public route inputs from explicit `x-sam-routes` plus + * `ports:` hints. This mirrors the normalized Compose parser's precedence: + * explicit service+port routes win, including `mode: private` suppressing a + * matching public `ports:` hint. + */ +export function collectComposePublishPublicRoutes( + doc: Record, + opts: ComposePublishPublicRouteOptions = {} +): PublicRouteInput[] { + if (!isPlainObject(doc.services)) { + return []; + } + + const portExtractor = opts.portExtractor ?? extractComposePorts; + const explicitRoutes = parseComposePublishExplicitRoutes(doc['x-sam-routes'], doc.services); + const explicitRouteKeys = new Set(explicitRoutes.map((route) => routeKey(route.service, route.port))); + const publicRoutes = explicitRoutes + .filter((route) => route.mode === 'public') + .map((route) => ({ service: route.service, port: route.port })); + + for (const [serviceName, rawService] of Object.entries(doc.services)) { + if (!isPlainObject(rawService) || 'provider' in rawService) { + continue; + } + + for (const port of portExtractor(serviceName, rawService.ports)) { + if (!explicitRouteKeys.has(routeKey(serviceName, port))) { + publicRoutes.push({ service: serviceName, port }); + } + } + } + + return publicRoutes; +} + /** * Assign loopback host ports + grey-cloud hostnames to an ordered list of * public routes. This is the shared derivation used by BOTH the normalized @@ -194,20 +351,7 @@ export function buildComposePublishRouteTargets( return []; } - const publicRoutes: PublicRouteInput[] = []; - for (const [serviceName, rawService] of Object.entries(doc.services)) { - if (!isPlainObject(rawService) || !Array.isArray(rawService.ports)) { - continue; - } - for (const portSpec of rawService.ports) { - const port = extractContainerPort(portSpec); - if (port !== null) { - publicRoutes.push({ service: serviceName, port }); - } - } - } - - return assignRouteTargets(publicRoutes, opts); + return assignRouteTargets(collectComposePublishPublicRoutes(doc), opts); } /** diff --git a/apps/api/tests/unit/services/compose-publish-apply.test.ts b/apps/api/tests/unit/services/compose-publish-apply.test.ts index 4e94a5ef8..d77cc936c 100644 --- a/apps/api/tests/unit/services/compose-publish-apply.test.ts +++ b/apps/api/tests/unit/services/compose-publish-apply.test.ts @@ -181,6 +181,96 @@ describe('buildComposePublishApplyPayload', () => { expect(doc.services.worker.ports).toBeUndefined(); }); + it('publishes explicit x-sam-routes public routes even without service ports', () => { + const composeWithExplicitRoute = `services: + app: + image: example/app:1 +x-sam-routes: + - service: app + port: 8000 + mode: public +`; + const result = buildComposePublishApplyPayload( + makeSubmission({ composeYaml: composeWithExplicitRoute, services: [] }), + OPTS + ); + const doc = parseYaml(result.composeYaml) as { services: Record }; + const route = result.routes.at(0); + + expect(result.routes).toHaveLength(1); + expect(route).toEqual( + expect.objectContaining({ + hostname: `r1-app-8000-${ENVIRONMENT_ID}.apps.${BASE_DOMAIN}`, + service: 'app', + containerPort: 8000, + }) + ); + expect(doc.services.app?.ports).toEqual([`127.0.0.1:${route?.hostPort}:8000`]); + }); + + it('lets x-sam-routes private suppress a matching ports-derived public route', () => { + const composeWithPrivateOverride = `services: + app: + image: example/app:1 + ports: + - "8000:8000" +x-sam-routes: + - service: app + port: 8000 + mode: private +`; + const result = buildComposePublishApplyPayload( + makeSubmission({ composeYaml: composeWithPrivateOverride, services: [] }), + OPTS + ); + const doc = parseYaml(result.composeYaml) as { services: Record }; + + expect(result.routes).toEqual([]); + expect(doc.services.app?.ports).toBeUndefined(); + }); + + it('does not duplicate a route when x-sam-routes and ports cover the same public port', () => { + const composeWithDuplicateHints = `services: + app: + image: example/app:1 + ports: + - "8000:8000" +x-sam-routes: + - service: app + port: 8000 + mode: public +`; + const result = buildComposePublishApplyPayload( + makeSubmission({ composeYaml: composeWithDuplicateHints, services: [] }), + OPTS + ); + const doc = parseYaml(result.composeYaml) as { services: Record }; + const route = result.routes.at(0); + + expect(result.routes).toHaveLength(1); + expect(route?.service).toBe('app'); + expect(route?.containerPort).toBe(8000); + expect(doc.services.app?.ports).toEqual([`127.0.0.1:${route?.hostPort}:8000`]); + }); + + it('fails fast for invalid compose-publish x-sam-routes entries', () => { + const composeWithInvalidRoute = `services: + app: + image: example/app:1 +x-sam-routes: + - service: app + port: 8000 + mode: external +`; + + expect(() => + buildComposePublishApplyPayload( + makeSubmission({ composeYaml: composeWithInvalidRoute, services: [] }), + OPTS + ) + ).toThrow(/x-sam-routes\[0\]\.mode.*public.*private/i); + }); + it('allows interpolated host ports because SAM rewrites host bindings', () => { const composeWithInterpolatedHostPort = `services: app: diff --git a/apps/api/tests/unit/services/deployment-routing.test.ts b/apps/api/tests/unit/services/deployment-routing.test.ts index dea1cd816..e2d5fb352 100644 --- a/apps/api/tests/unit/services/deployment-routing.test.ts +++ b/apps/api/tests/unit/services/deployment-routing.test.ts @@ -276,6 +276,54 @@ describe('collectEnvironmentRouteHostnames', () => { ]); }); + it('reconstructs compose-publish hostnames from x-sam-routes without ports', () => { + const composePublishSubmission = { + reference: 'v1', + composeYaml: `services: + web: + image: example/web +x-sam-routes: + - service: web + port: 8000 + mode: public +`, + services: [{ serviceName: 'web', pushedRef: 'registry.example/web@sha256:aaa' }], + }; + + const hostnames = collectEnvironmentRouteHostnames( + [JSON.stringify(composePublishSubmission)], + opts + ); + + expect(hostnames).toEqual([ + 'r1-web-8000-01ktx9m6j0tpmgw0cq98hq1eaw.apps.sammy.party', + ]); + }); + + it('lets compose-publish x-sam-routes private suppress captured ports during reconstruction', () => { + const composePublishSubmission = { + reference: 'v1', + composeYaml: `services: + web: + image: example/web + ports: + - "8000:8000" +x-sam-routes: + - service: web + port: 8000 + mode: private +`, + services: [{ serviceName: 'web', pushedRef: 'registry.example/web@sha256:aaa' }], + }; + + const hostnames = collectEnvironmentRouteHostnames( + [JSON.stringify(composePublishSubmission)], + opts + ); + + expect(hostnames).toEqual([]); + }); + it('skips manifests whose route set exceeds the configured span', () => { const hostnames = collectEnvironmentRouteHostnames([JSON.stringify(manifest())], { ...opts, diff --git a/apps/www/src/content/docs/docs/guides/app-deployments.md b/apps/www/src/content/docs/docs/guides/app-deployments.md index 3f6b63e79..6b6e62600 100644 --- a/apps/www/src/content/docs/docs/guides/app-deployments.md +++ b/apps/www/src/content/docs/docs/guides/app-deployments.md @@ -13,7 +13,7 @@ Agents publish with a single tool: This tool requires the named deployment environment to be active, agent deployment to be enabled by a user, and the agent profile to satisfy that environment's policy. -The release submission format is Docker Compose YAML with SAM extensions. SAM supports multi-service Compose stacks, preserves service topology including Docker Model Runner `provider:` services, and derives public routes from either `x-sam-routes` or compose service `ports:`. +The release submission format is Docker Compose YAML with SAM extensions. SAM supports multi-service Compose stacks, preserves service topology including Docker Model Runner `provider:` services, and derives public routes from compose service `ports:` by default. Use `x-sam-routes` only when you need an explicit override, such as exposing a service port without `ports:` or marking a matching `ports:` entry as `private`. ```yaml services: @@ -30,14 +30,20 @@ services: interval: 30s timeout: 5s retries: 3 + ports: + - "3000" volumes: app-data: {} +``` + +To override route behavior explicitly: +```yaml x-sam-routes: - service: web port: 3000 - mode: public + mode: private ``` Submit the file to the release endpoint with a YAML content type: diff --git a/apps/www/src/content/docs/docs/reference/api.md b/apps/www/src/content/docs/docs/reference/api.md index 5224e18e9..160a269a1 100644 --- a/apps/www/src/content/docs/docs/reference/api.md +++ b/apps/www/src/content/docs/docs/reference/api.md @@ -136,7 +136,7 @@ Create a task record. ### `POST /api/projects/:projectId/environments/:envId/releases` Create a deployment release for an environment. -Preferred body: Docker Compose YAML with `Content-Type: text/yaml`, `application/yaml`, `text/x-yaml`, or `application/x-yaml`. Compose submissions may use `x-sam-routes` for routes and `x-sam-secret` environment values for secret references. +Preferred body: Docker Compose YAML with `Content-Type: text/yaml`, `application/yaml`, `text/x-yaml`, or `application/x-yaml`. Compose submissions derive public routes from service `ports:` by default and may use `x-sam-routes` for explicit route overrides. They may also use `x-sam-secret` environment values for legacy secret references. Raw manifest JSON is still accepted for backward compatibility when another content type is used. diff --git a/packages/shared/src/compose-parser/parse.ts b/packages/shared/src/compose-parser/parse.ts index 2b705461b..da8143378 100644 --- a/packages/shared/src/compose-parser/parse.ts +++ b/packages/shared/src/compose-parser/parse.ts @@ -139,7 +139,8 @@ export function parseCompose(yamlString: string): ComposeParseResult { if (routes.length === 0) { errors.push({ path: 'x-sam-routes', - message: 'At least one route must be defined. Add an "x-sam-routes" entry to expose a service.', + message: + 'At least one route must be defined. Add a service "ports:" or "expose:" entry, or an "x-sam-routes" route.', }); } diff --git a/tasks/backlog/2026-06-24-compose-publish-x-sam-routes.md b/tasks/archive/2026-06-24-compose-publish-x-sam-routes.md similarity index 69% rename from tasks/backlog/2026-06-24-compose-publish-x-sam-routes.md rename to tasks/archive/2026-06-24-compose-publish-x-sam-routes.md index a71587c24..262a93b2c 100644 --- a/tasks/backlog/2026-06-24-compose-publish-x-sam-routes.md +++ b/tasks/archive/2026-06-24-compose-publish-x-sam-routes.md @@ -29,31 +29,35 @@ Desired product behavior: ## Implementation checklist -- [ ] Add a shared helper inside `compose-publish-apply.ts` that parses top-level `x-sam-routes` from the captured compose document and returns explicit route definitions with structured validation errors. -- [ ] Update compose-publish public route collection so explicit `x-sam-routes` entries take precedence over `ports:` hints for the same `service` + `port`. -- [ ] Ensure `mode: private` suppresses public exposure for the same `service` + `port` even when the service has `ports:`. -- [ ] Ensure `x-sam-routes` `mode: public` can publish a route even if the service has no `ports:` entry by injecting the loopback binding into the rendered Compose output. -- [ ] Preserve current `ports:` happy-path behavior, including route order, loopback rewrite, artifact/provider handling, and deterministic hostname/hostPort assignment. -- [ ] Add focused regression tests in `apps/api/tests/unit/services/compose-publish-apply.test.ts`: - - [ ] `ports:` alone still creates a public route and loopback binding. - - [ ] `x-sam-routes` public without `ports:` creates a route and loopback binding. - - [ ] `x-sam-routes` private plus matching `ports:` suppresses the public route and removes the service `ports:` entry. - - [ ] explicit public route plus matching `ports:` does not duplicate. - - [ ] invalid route mode/service/port fails with an actionable compose-publish error. -- [ ] Add/update route-target reconstruction tests in `apps/api/tests/unit/services/deployment-routing.test.ts` so stale DNS/custom-domain consumers also honor stored compose-publish `x-sam-routes`. -- [ ] Clarify docs/tool guide wording: use `ports:` for normal public routes; use `x-sam-routes` for explicit public routes without `ports:` or to mark a matching port private. -- [ ] Run focused tests, typecheck/lint, full quality suite, specialist review, staging verification, PR, CI, merge, and production deploy monitoring per `/do`. +- [x] Add a shared helper in `deployment-routing.ts` that parses top-level `x-sam-routes` from the captured compose document and returns explicit route definitions with structured validation errors for both apply-time and reconstruction-time callers. +- [x] Update compose-publish public route collection so explicit `x-sam-routes` entries take precedence over `ports:` hints for the same `service` + `port`. +- [x] Ensure `mode: private` suppresses public exposure for the same `service` + `port` even when the service has `ports:`. +- [x] Ensure `x-sam-routes` `mode: public` can publish a route even if the service has no `ports:` entry by injecting the loopback binding into the rendered Compose output. +- [x] Preserve current `ports:` happy-path behavior, including route order, loopback rewrite, artifact/provider handling, and deterministic hostname/hostPort assignment. +- [x] Add focused regression tests in `apps/api/tests/unit/services/compose-publish-apply.test.ts`: + - [x] `ports:` alone still creates a public route and loopback binding. + - [x] `x-sam-routes` public without `ports:` creates a route and loopback binding. + - [x] `x-sam-routes` private plus matching `ports:` suppresses the public route and removes the service `ports:` entry. + - [x] explicit public route plus matching `ports:` does not duplicate. + - [x] invalid route mode/service/port fails with an actionable compose-publish error. +- [x] Add/update route-target reconstruction tests in `apps/api/tests/unit/services/deployment-routing.test.ts` so stale DNS/custom-domain consumers also honor stored compose-publish `x-sam-routes`. +- [x] Clarify docs/tool guide wording: use `ports:` for normal public routes; use `x-sam-routes` for explicit public routes without `ports:` or to mark a matching port private. +- [x] Run focused tests and API typecheck/lint. +- [x] Run full local quality suite before review. +- [ ] Run specialist review, staging verification, PR, CI, merge, and production deploy monitoring per `/do`. + +The remaining unchecked item is the `/do` lifecycle after implementation validation. It is tracked in `.do-state.md`; it is not an unimplemented code or documentation requirement. ## Acceptance criteria -- [ ] Agent-first `build_and_publish` releases support the same route precedence as normalized Compose releases for public/private `x-sam-routes` versus `ports:` hints. -- [ ] A service with `ports:` and no explicit route still exposes a public app route. -- [ ] A service with `x-sam-routes: [{ service, port, mode: public }]` and no `ports:` still exposes a public app route. -- [ ] A service with matching `ports:` and `x-sam-routes: [{ service, port, mode: private }]` is not publicly exposed. -- [ ] Duplicate explicit/public and `ports:` hints for the same service/port do not create duplicate route targets or duplicate loopback bindings. -- [ ] Invalid `x-sam-routes` entries in compose-publish releases fail before apply with clear error messages. -- [ ] Public docs and the MCP deployment guide match the implemented behavior. -- [ ] Local tests and staging verification prove the route behavior before merge. +- [x] Agent-first `build_and_publish` releases support the same route precedence as normalized Compose releases for public/private `x-sam-routes` versus `ports:` hints. +- [x] A service with `ports:` and no explicit route still exposes a public app route. +- [x] A service with `x-sam-routes: [{ service, port, mode: public }]` and no `ports:` still exposes a public app route. +- [x] A service with matching `ports:` and `x-sam-routes: [{ service, port, mode: private }]` is not publicly exposed. +- [x] Duplicate explicit/public and `ports:` hints for the same service/port do not create duplicate route targets or duplicate loopback bindings. +- [x] Invalid `x-sam-routes` entries in compose-publish releases fail before apply with clear error messages. +- [x] Public docs and the MCP deployment guide match the implemented behavior. +- [x] Local tests prove the route behavior before PR review; staging verification remains the `/do` merge gate before merge. ## References