| index fe12cda75..5efe2e338 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -112,6 +112,15 @@ func runAdd(args []string) { |
| log.Fatalf("add %s: --backing unit with --unit %s: a unit-backed route's process is the carlos-app@%s unit the platform derives and already restarts, "+ |
| "so a second unit name here would be two answers to one question — drop one of the two flags", host, *unit, host) |
| } |
| + // The root pass delivers config to a stamped route only in one shape |
| + // (appconfig.StampShape); this door must not mint a row it would refuse |
| + // every pass. --socket is refused even though the default below would |
| + // add one for a bare instance: a stamped route needs --addr. |
| + if *unit != "" { |
| + if *socket != "" || *addr == "" || *hibernate { |
| + log.Fatalf("add %s: --unit %s needs --addr and neither --socket nor --hibernate: the root pass delivers config to a stamped route only when a unit owns an addr-backed, always-on process", host, *unit) |
| + } |
| + } |
| if *socket == "" && *addr == "" && *kind == "instance" { |
| if *backing == registry.BackingUnit { |
| // The carlos-app@<host>.socket unit's path — enable that unit |
| @@ -1324,6 +1333,9 @@ func runRouteHibernate(r *registry.Registry, rt registry.Route, on, dryRun bool) |
| "and a hibernation flip here is reverted on the next reconcile pass. This verb does not own this row", rt.Host) |
| } |
| if on { |
| + if err := hibernateRail(rt); err != nil { |
| + log.Fatalf("route: %s: %v", rt.Host, err) |
| + } |
| store, pub := deploymentReadSeam() |
| daemon, note, proven := routeDaemonCheck(context.Background(), store, pub, release.RouteAccount(rt), rt) |
| if !proven { |
| @@ -1484,7 +1496,7 @@ func backingRail(rt registry.Route, backing string) error { |
| // most needed. What it does instead is print what the next adopt pass will do |
| // with the value, at the moment of typing. |
| func runRouteUnit(r *registry.Registry, rt registry.Route, unit string, dryRun bool) { |
| - if err := unitRail(rt); err != nil { |
| + if err := unitRail(rt, unit); err != nil { |
| log.Fatalf("route: %s: %v", rt.Host, err) |
| } |
| prefix := "" |
| @@ -1509,23 +1521,39 @@ func runRouteUnit(r *registry.Registry, rt registry.Route, unit string, dryRun b |
| } |
| |
| // unitRail is the row-shape half of runRouteUnit's refusals, split out as a |
| -// pure function for the same reason backingRail is: a rail nobody can |
| -// exercise without running the process is a rail nobody tests. |
| +// pure function for the same reason backingRail is. It validates the ROW |
| +// THAT WOULD RESULT (spec 2026-09-07-named-unit-config-delivery-design.md |
| +// §3.1, codex R3-5): clearing a stamp (unit == "") is always allowed, so |
| +// that no row can be stranded; stamping refuses a unit-backed row (two |
| +// answers to one question) and a row outside the supported stamped shape |
| +// (appconfig.StampShape: an addr upstream, no socket, no hibernation), |
| +// which the root pass would refuse every pass anyway. |
| // |
| -// Deliberately NARROWER than backingRail. It does not refuse Platform rows: |
| -// this column is not derived by the domains reconciler (mintedRoute never |
| -// sets it) and registry.Upsert preserves a non-empty value, so a stamp there |
| -// survives a re-mint rather than being silently reverted — and the |
| -// deployment's own console can legitimately be a platform-minted row. It does |
| -// not refuse non-instance kinds either: a `service` route is precisely a |
| -// sibling process the platform only proxies, and naming the unit that owns it |
| -// is a true statement, even though adoption has no version to move for one |
| -// today. |
| -func unitRail(rt registry.Route) error { |
| +// Deliberately NOT refused: Platform rows and service rows — see the |
| +// previous version's reasoning, which still holds. |
| +func unitRail(rt registry.Route, unit string) error { |
| + if unit == "" { |
| + return nil |
| + } |
| if rt.Backing == registry.BackingUnit { |
| return fmt.Errorf("unit-backed route — its process is the carlos-app@%s unit the platform derives and already restarts, "+ |
| "so a second unit name here would be two answers to one question (drop the backing first with `carlos route --backing \"\"` if this route really is owned by a bespoke service)", rt.Host) |
| } |
| + proposed := rt |
| + proposed.Unit = unit |
| + if err := appconfig.StampShape(proposed); err != nil { |
| + return fmt.Errorf("%v — the root pass delivers config only to an addr-backed, non-hibernating route", err) |
| + } |
| + return nil |
| +} |
| + |
| +// hibernateRail refuses hibernation on a stamped row: the root pass |
| +// restarts a stamped unit on config change, and a hibernating route is the |
| +// activator's to wake, so the two would fight over one process. |
| +func hibernateRail(rt registry.Route) error { |
| + if rt.Unit != "" { |
| + return fmt.Errorf("route names unit %s — a stamped route is restarted by the root config pass and cannot also hibernate; clear the stamp first (`carlos route --host %s --unit -`)", rt.Unit, rt.Host) |
| + } |
| return nil |
| } |
| |