Skip to main content

Module inner_door

Module inner_door 

Source
Expand description

The inner door planner (R870-F23) — the service.toml + domain-manifest join that produces a passway PASSWAY_PATH_ROUTES_FILE.

§What an inner door is, and what it is not

R870-F15 landed path routing in passway; R870-T18 gave it a config surface (a JSON mount table named by PASSWAY_PATH_ROUTES_FILE). Both are the consumer. This module is the producer: given a service’s declared components and the domain manifest that routes them, it answers “what mount table does this service’s own door need, if any”.

It is an inner door because it sits behind the service’s public one, on loopback. The public door owns a hostname and terminates TLS; the inner door owns one hostname’s paths and splits them across units that deploy independently. That split is the only thing it does — and it is the thing the public door structurally cannot do, because the public tier routes by SNI/Host and a request’s path is not visible until after that.

§The join carries no new vocabulary

R870-F15 claimed the join needs nothing new, and that holds up. Every input already exists:

FieldSource
mountServiceComponent::mount, normalized by normalize_mount
headersthe route the domain’s table gives that mount’s path (DomainConfig::route_for_path)
tierServiceComponent::deploy — the one thing R870-F23 added
upstreamsplacement-time, so it is InnerDoorPlan::routes_file’s argument, not a config field

The mount/route agreement is not re-derived here: [CloudConfig::cross_ref_validate] already proves a component’s mount and its route’s path prefix are the same string, so the lookup below cannot silently mismatch — a config where it would have does not load.

§Headers come from the ONE route table (R898-F2)

The header column is not this module’s to derive. R898-F1 compiled a domain’s declared routes into one ordered RouteTable, and every tier — the Worker, mesofact, the outer door’s ROUTE_HEADERS — answers “what governs this path” by that table’s rule. [headers_for] therefore asks the table’s rule too, rather than re-deriving the join; two producers of one fact is precisely what the R898 relay exists to delete.

And this tier is the only one that applies those headers. Operator call, 2026-09-12: the outer passway door stays a pure HOST router (path routing and host routing are mutually exclusive at its boot — HOST_ROUTING_ENV in passway’s main.rs), so it applies no per-path headers and cannot double-apply these. That settles R870-F23’s open ownership question, and it is what makes a bundle-tier component at a non-root mount keeping its own entry here CORRECT rather than redundant: the entry points at the same bundle upstream as the root and exists purely to carry that mount’s headers. Deleting it as a duplicate would silently strip them.

§The two admission rules

Both belong here, never to passway: passway proxies whatever PathRouter it is handed and has no view of how many components a service declares.

  1. A service with one independently-deployed unit gets no inner tier at all. Enforced by construction — plan answers Ok(None) below two units, so there is no config to write and no process to supervise. That makes the negative assertable on the absence of a plan rather than on a site staying up, which is the only form of that assertion that can fail loudly.
  2. A component cannot be both bundle-staged and its own workload. Enforced by DeployTier being one field with two values rather than two independent flags: the contradictory state has no spelling. What remains checkable — that two components do not claim one mount — lives in cross_ref_validate’s existing loop, widened rather than duplicated.

§Grouping is by deployed UNIT, not by component

Every bundle-tier component of a service shares ONE bundle workload (config 1, R870-B11), so they contribute one upstream between them — DeployedUnit::Bundle. They still contribute their own mounts, because a mount is where the bundle stores that component’s output (app/dist/<mount>/) and because the domain manifest may give that path response headers the root does not have. So N bundle components produce N mounts and one unit, and it is the unit count that rule 1 keys on.

@yah:ticket(R931-B4, “Inner door deploy is routed to kamaji’s containerd backend and 500s on local/passway:inner-door@ (empty digest)”) @yah:status(review) @yah:at(2026-09-22T01:09:31Z) @yah:assignee(agent:bundle-anthropic-miravel) @yah:parent(R931) @yah:handoff(“Tier: Warrior — this blocks the whole inner-door feature on a real hostname; it is the first use of it outside yah and it does not work. MEASURED 2026-09-21 on prod. SYMPTOM: yah cloud mirror up noisetable-marketing --env prod reconciled both static components fine, then failed deploying passway-inner-noisetable-marketing with yubaba returned 500: kamaji deploy_workload: BackendRefused: containerd: image not found in containerd: cr-less ref \"local/passway:inner-door@\" — pre-pull required. NOTE THE EMPTY DIGEST after the @. WHY THAT IS WRONG RATHER THAN A MISSING IMAGE: InnerDoorPlan::workload (inner_door.rs:553) constructs exactly that ImageRef — registry "local", repository "passway", tag "inner-door", digest "" — and the comment at the site says "Identity metadata only — the native backend pulls nothing". The surrounding module doc adds that the route table travels as WorkloadSpec::files and that "only kamaji’s NATIVE backend materializes those; every other backend refuses the spec by name rather than starting the door against a file that is not there", and deploy_inner_door (app/yah/cli/src/cloud.rs:12173) has a "rejected" arm whose message says the same. What happened is NEITHER: the spec reached containerd and came back as a 500 asking for a pull, so it never reached a native backend to be accepted or refused by name. WHAT I CHECKED: both us-east-001 and us-west-001 run kamaji with --native-exec-dir /var/lib/yah/kamaji/native, so the flag is present on both; both native dirs are effectively empty (west has only a headscale subdir), so nothing named passway is staged; /usr/local/bin/passway DOES exist on the node but as a systemd-managed host binary — passway, passway-demux and passway-http-router are all systemd units, so the OUTER door is not a container workload at all. HYPOTHESIS, NOT ESTABLISHED: kamaji’s native supervisor is behind #[cfg(feature = \"native-exec\")] (seen in oss/kamaji/crates/kamaji-bin/src/server.rs), so a deployed kamaji built without that feature would behave exactly like this; I could not confirm the installed binary’s feature set and stopped rather than guess. Start there. CLEANUP DONE: the half-created record was destroyed via POST /workloads/passway-inner-noisetable-marketing/destroy on us-east-001, so a retry starts clean; a leftover /run/netns/passway-inner-noisetable-marketing mount unit was still active on that node afterwards.”) @yah:handoff(“ITEM 1 (backend-selection site): oss/kamaji/crates/kamaji-bin/src/server.rs:2205 deploy_container_backend(). Predicate is spec.wants_native_exec() at line 2212 (falls to deploy_native_exec if true), then spec.wants_microvm() at 2216, then the #[cfg(feature="containerd-integration")] containerd arm at 2220 if ctx.containerd is Some. wants_native_exec() (oss/yah-base/crates/workload-spec/src/lib.rs:3411) is purely annotation-based: annotations["yah.exec"] == "native" (NATIVE_EXEC_ANNOTATION/NATIVE_EXEC_VALUE, lib.rs:4356/4361). Routing has nothing to do with runtime flags at this call site — it only reads the spec.”) @yah:handoff(“ITEM 2 (why it landed on containerd): CONFIRMED, and the prior hypothesis (kamaji built without the native-exec cargo feature) is KILLED, not just unconfirmed. Root cause: InnerDoorPlan::workload() (oss/yubaba/crates/cloud/src/inner_door.rs, was line 617) built its WorkloadSpec with annotations: HashMap::new() – the native-exec marker was simply never set on the inner-door spec. Confirmed by contrast with the one other native-exec appliance in the tree, headscale_appliance.rs:356 appliance_spec(), which explicitly does annotations.insert(NATIVE_EXEC_ANNOTATION, NATIVE_EXEC_VALUE) with a comment naming exactly this routing. Since wants_native_exec() reads only spec.annotations, the inner-door spec would have fallen through to containerd regardless of whether the installed kamaji has –native-exec-dir configured or the native-exec cargo feature compiled in – both of which were already confirmed present on both prod nodes by the prior courier, making the feature-flag theory moot even before this fix.”) @yah:handoff(“ITEM 3 (guard landed): Fixed at the true source – oss/yubaba/crates/cloud/src/inner_door.rs InnerDoorPlan::workload(): added annotations.insert(NATIVE_EXEC_ANNOTATION, NATIVE_EXEC_VALUE) and wired annotations (was HashMap::new()) into the WorkloadSpec literal, plus a NATIVE_EXEC_ANNOTATION/NATIVE_EXEC_VALUE import. This makes the spec actually reach deploy_native_exec (server.rs:2429), which ALREADY HAD the exact diagnosable-refusal guard item 3 asked for and just never got exercised: if ctx.native is None it returns BackendRefused naming the missing capability verbatim – "native backend not configured – start kamaji with –native-exec-dir" (feature present, –native-exec-dir unset) or "kamaji built without the native-exec feature" (feature absent) – rather than silently falling through to containerd. No change was needed in kamaji itself; the guard was already correct, just unreachable for this workload.”) @yah:verify(“Baseline BEFORE any edit, measured via revert-with-Edit (git stash is blocked in this camp) + cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib inner_door -- --test-threads=1: 21 passed, 0 failed (inner_door::tests + reconciler::ingress::tests::an_inner_door_overrides…).”) @yah:verify(“AFTER the fix, same command: 21 passed, 0 failed – no regression, no new test needed since no assertion in this module reads spec.annotations.”) @yah:verify(“cargo check --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --all-targets: Finished clean (0 errors); the 3 warnings present are pre-existing and in files this change did not touch (app_manifest.rs:765 dead field, pond_door.rs:921 dead fn, reconciler/mod.rs:1092 non-snake-case test name).”) @yah:gotcha(“This fix has NOT been verified live on prod – I do not have a way to redeploy noisetable-marketing from this session per the ticket’s mandate (three things only, then stop). Whoever next runs yah cloud mirror up noisetable-marketing --env prod should expect one of two outcomes: the inner door now deploys via kamaji’s native backend (success), or it now gets a clean BackendRefused naming the missing native-exec capability on that node (which would mean us-east-001/us-west-001’s –native-exec-dir wiring needs a second look despite the prior courier’s check) – either is a real diagnostic improvement over the empty-digest containerd 500.”) @yah:gotcha(“Did not touch app/yah/cli/src/cloud.rs (owned by another courier) or oss/yubaba/crates/cloud/src/config.rs (dirty under @Miravel:eclipse) – neither needed a change for this fix.”) @yah:gotcha(“git-policy is defer in this camp right now, so the fix is left as an uncommitted working-tree edit for the operator/git-sweep to commit – commit_sha is null by design, not an omission.”) @yah:handoff(“FOLLOW-UP (same session, warm context): landed the guard the operator asked for, at a DIFFERENT and more precise invariant than item 3’s first pass argued for. Q1 (construction sites): grepped every non-empty WorkloadSpec{files:...} literal and every real (non-test) NATIVE_EXEC_ANNOTATION/MICROVM_EXEC_VALUE write in oss/+app/+crates/. Found THREE production construction/mutation sites, not two: (a) oss/yubaba/crates/yubaba/src/headscale_appliance.rs:356 appliance_spec() – builds from scratch, sets the annotation correctly. (b) oss/yubaba/crates/cloud/src/inner_door.rs InnerDoorPlan::workload() – built from scratch, annotation MISSING, now fixed this session. (c) oss/qed/crates/velveteen-exec/src/remote.rs:989/1025 mark_native_exec()/mark_microvm() – MUTATES an already-built container-shaped forge spec into native/microvm form and correctly sets the annotation, but that spec keeps a REAL non-empty digest (a resolved build image), so it was never exposed to this bug class regardless. Every other NATIVE_EXEC_ANNOTATION/MICROVM_EXEC_VALUE hit in the tree (oss/yubaba/crates/yubaba/src/service_records.rs, oss/yubaba/crates/cloud/src/config.rs, several kamaji test files) is inside #[cfg(test)] – test fixtures, not deploy-path construction. So: 2 from-scratch sites total, exactly matching the ticket’s original count, one of which was the bug.”) @yah:handoff(“Q1 ALSO SURFACED A STALE DOC CLAIM WORTH RECORDING: inner_door.rs’s own module doc says ‘every other backend refuses the spec by name rather than starting the door against a file that is not there’ about WorkloadSpec::files. That is FALSE as of R870-F27 (oss/kamaji/crates/kamaji-containerd-core/src/lib.rs plan_spec_files/stage_spec_files, wired into containerd’s deploy path at oss/kamaji/crates/kamaji/src/containerd.rs:425): containerd now materializes files too (bind-mounts them into the container), and a real container spec CAN legitimately carry files – proved by oss/yubaba/crates/yubaba/src/spec_redact.rs’s own redaction test building a plain container_spec with files. Only the DOCKER backend still has no file-staging code at all. This means ‘files present + no native annotation’ is NOT a safe general invariant (it would reject legitimate containerd config-injection workloads) – I did not write that check. Did not edit the stale doc comment itself since it lives in inner_door.rs’s module header, outside what this follow-up touched; flagging it here rather than silently leaving a wrong claim uncorrected.”) @yah:handoff(“Q2/DECISION (guard landed): the real, general invariant is about image.digest, not files: an empty digest is only valid on a spec whose exec_substrate() is Native or MicroVm (both backends document image as ‘identity metadata only, nothing is pulled’ – wants_native_exec/wants_microvm doc comments in oss/yah-base/crates/workload-spec/src/lib.rs). A Container-substrate spec with an empty digest has no backend that can serve it: containerd/docker both resolve-by-digest before anything else runs (confirmed by reading oss/kamaji/crates/kamaji/src/containerd.rs:389’s resolve_image_target_digest call, which is exactly where the original prod 500 fired). Landed the check in oss/yah-base/crates/workload-spec/src/validate.rs shape() – the ONE shape gate both POST /workloads/deploy and POST /workloads/validate run via check_deploy_body() in oss/yubaba/crates/yubaba/src/lib.rs:5866 (read-only; not edited, and not one of the two restricted files), before admission or any backend selection. This is a shared crate (yah-base/workload-spec), not kamaji internals and not cloud.rs/config.rs – in blast radius. I did NOT reach into kamaji-bin/src/server.rs’s deploy_container_backend (server.rs:2205) or wants_native_exec’s caller (server.rs:2212) even though the operator listed it as an option: that file is a 9000+ line hot spot with multiple concurrently-referenced live tickets (R850-F1, R870-F27, R881-T3, R908-T1 all cited in the very functions this ticket touches) and editing it as a one-shot courier without live-peer context felt like exactly the case CLAUDE.md’s shared-tree doctrine says to yield on rather than force – naming it here per the operator’s own fallback instruction. New Err: ShapeError::Field{path: FieldPath::Image, reason: names both missing-annotation values and the unpullable ref}. Added one bad-fixture regression test (tests/fixtures/bad/image_digest_empty_no_native_marker.json, a copy of the actual inner-door shape pre-fix) and one valid-fixture regression test (tests/fixtures/valid/native_exec_empty_digest.json, the same shape WITH the annotation, to prove the check does not over-fire) – both wired into the existing table-driven tests in tests/shape_fixtures.rs.”) @yah:verify(“OPEN QUESTION FOR THE REVIEWER — the second-pass guard reports 108 passed / 0 failed BEFORE and AFTER, while also claiming "regression fixtures for both the rejected and accepted shape" were added. An unchanged test count is consistent with fixtures added INSIDE an existing table-driven test (coverage rises, count does not), but it is equally consistent with fixtures that no assertion actually reads. Confirm by reading oss/yah-base/crates/workload-spec/src/validate.rs: check that the empty-digest-requires-Native/MicroVm rule is asserted by a test that FAILS when the check is removed. A guard that cannot fail is not a guard. The leader did not re-run any of this — see the relay-level note on R931.”) @yah:verify(“OPEN QUESTION ABOVE IS NOW ANSWERED — THE GUARD IS REAL. A separate verifying courier mutation-tested it rather than trusting the unchanged count: disabling the check in validate.rs (if false && spec.image.digest.is_empty() && ...) via Edit made shape_fixtures::bad_fixtures_produce_expected_shape_error FAIL with "expected ShapeError for fixture image_digest_empty_no_native_marker but got Ok([])". The guard was then restored by hand with Edit (never git) and the suite returned to green; git diff on validate.rs afterwards shows only the implementing courier’s own added lines, so the round-trip left no residue. So the 108/108-before-and-after count was the benign case — fixtures folded into an existing table-driven test, coverage genuinely added. Also independently confirmed: InnerDoorPlan::workload() sets NATIVE_EXEC_ANNOTATION/NATIVE_EXEC_VALUE at inner_door.rs:583-584 with imports at :114-115. Note on the "108": that is the tests/main.rs integration suite; the crate also runs 207 lib tests, green in the same runs. cargo build -p yah exit 0.”)

@yah:ticket(R931-B8, “SSR self-fetch to own public hostname (noisetable.com/api/issues) 500s from inside mesofact-spa even though the same URL succeeds externally”) @yah:status(review) @yah:at(2026-09-22T16:17:38Z) @yah:assignee(agent:bundle-anthropic-ashguard) @yah:parent(R931) @yah:severity(medium) @yah:next(“Get SSR-side logs/stderr for the site (mesofact-spa) component on us-east-001 for a request to /issues to see what fetch(‘https://noisetable.com/api/issues’) actually throws or returns from inside the container.”) @yah:next(“Confirm whether other mesofact-spa routes that self-fetch their own public origin have the same failure, or if this is specific to issues.tsx’s URL construction.”) @yah:next(“Once root-caused, the fix likely belongs in mesofact/passway (egress/hairpin routing for self-referential fetches) or in a documented convention for SSR handlers to use an internal mesh address instead of the public hostname.”) @yah:handoff(“DIAGNOSED 2026-09-22 by noisetable R733-T26: NOT a hairpin/egress defect, and the page never fetched https://noisetable.com at all. mesofact’s SSR dispatch strips Host and synthesizes req.url = "http://dev{path_and_query}" (oss/mesofact/crates/mesofact/src/server.rs:1006, dispatch_to_ssr). noisetable’s web/landing/src/issues.tsx built its API URL as new URL("/api/issues", new URL(request.url).origin), i.e. http://dev/api/issues, which resolves nowhere -> caught -> 500 ‘Unable to load issues’. Fixed on the noisetable side (base = SITE_ORIGIN = https://noisetable.com) and redeployed; live /issues now 200 listing the fixture, so a self-fetch to the site’s own public hostname from inside mesofact-spa WORKS. Recommend closing this bug, or re-scoping it to a mesofact docs/API gap: SSR fetch handlers get a synthetic origin with no documented way to learn the public host (no Host / X-Forwarded-Host passthrough), which is a trap for any handler that reads request.url’s origin.”) @yah:handoff(“Re-scoped per the R733-T26 diagnosis (not a hairpin/egress bug; the SSR request URL had a fake origin) and fixed in mesofact: dispatch_to_ssr no longer hardcodes http://dev. The new public_origin() (oss/mesofact/crates/mesofact/src/server.rs, beside should_retry_status) builds request.url from X-Forwarded-Host, falling back to Host. The scheme comes from X-Forwarded-Proto (https only), otherwise http. A host value that is not authority-shaped, or is missing, becomes http://localhost. So new URL(request.url).origin is now the site’s own public origin. Test fixtures in ssr.rs / mesofact-ssr that use http://dev/… were left alone: they are hand-built DispatchRequests, not the server path.”) @yah:verify(“cd oss/mesofact && cargo test -p mesofact –features ssr –lib server:: — 72 passed, including the new ssr_request_url_carries_public_origin, which covers 4 cases: plain Host; XFH+XFP overriding an internal Host; an injection-shaped Host rejected; no Host.”) @yah:gotcha(“Not deployed. Prod picks this up only after a mesofact release and a noisetable site redeploy. Passway does not set X-Forwarded-Proto (grep found none in oss/yubaba), so behind a TLS edge the origin reads http://noisetable.com. That is right for host-based self-fetch, but it is not the https origin. noisetable’s SITE_ORIGIN workaround stays valid either way.”) @yah:handoff(“HOTSHIPPED 2026-09-22 (operator call A). Runtime mesofact/0.8.41-r931b8 was built from the yah tree by yah qed run mesofact-musl (run dbce5363, no CDN publish). It went out through yah cloud bundle publish-runtime: x86_64-musl (blake3 f62bba56…) to bucket noisetable-marketing, aarch64-musl (5505a05b…) to noisetable-staging. The noisetable mirrors now pin 0.8.41-r931b8: prod.toml was 0.8.32 (rollback note inline), staging.toml was 0.8.40. PROD: yah cloud apply --env prod --service noisetable-marketing EXIT=0, bundle 2aae406a…, us-east-001 Running. Live after the apply: / 200, /issues 200 (lists issues), /api/issues 200, /app/ 200; COOP/COEP present on / and /app/. STAGING (canary) apply EXIT=1, for a reason unrelated to the runtime: the inner door refused because staging’s issues component (kind=binary) has no resolved address. The bundle is Running on us-west-011 on the new runtime, / and /app/ serve with COOP/COEP, and /issues renders its ‘Unable to load issues’ fallback because staging /api/issues is 404.”) @yah:cleanup(“noisetable’s prod.toml and staging.toml pin the prerelease runtime 0.8.41-r931b8, which exists only in the noisetable-marketing and noisetable-staging buckets. After the next real mesofact release, move both pins to it; x86_64 and aarch64 runtime assets must be published to those buckets first. Staging’s issues workload component has no address, so the staging inner door refuses; this is in the inner-door area the R931 parent covers.”)

Structs§

InnerDoorMount
One mount of an inner door’s table, before upstream addresses exist.
InnerDoorPlan
A service’s inner door, as configuration — everything but the addresses.

Enums§

DeployedUnit
Which deployed thing serves a mount.

Constants§

DISCOVERY_INTERVAL_SECS
How often an inner door re-reads its units’ service records, in seconds. See InnerDoorPlan::workload.
INNER_DOOR_BINARY
The passway binary every node carries, installed by the yubaba release tarball’s control_plane_install. The inner door is the same binary as the public door — one door implementation, two configurations, which is the property R870-F15 built path routing to preserve.
INNER_DOOR_HOST
The only address an inner door ever binds, and the only one the outer door ever dials it at. Literal rather than a parameter — see InnerDoorPlan::workload.
INNER_DOOR_PORT_HIGH
High end of the window listen_port picks from, inclusive.
INNER_DOOR_PORT_LOW
Low end of the window listen_port picks from, inclusive.
ROUTES_DIR
Where a node keeps generated route tables. Same directory the demux and http-router tables already live in.
ROUTES_SCHEMA_VERSION
schema_version of the route table this module writes. Must match passway’s path_routes_file::SCHEMA_VERSION; a mismatch is a boot failure on the door naming both numbers, which is the intended way for a producer/consumer skew to surface (see that module’s doc).

Functions§

component_workload_ident
The mesh identity a DeployTier::Workload component registers its service record under.
listen_port
The loopback port a service’s inner door listens on — derived from the service name, so every apply of an unchanged tree renders the same number.
passway_mount
Translate a normalized mount ("", "app") to passway’s spelling ("", "/app").
plan
Plan the inner door for one service, or answer None when it should not have one.