// Attach and detach a custom domain — the core both doors share (#82). // Spec: docs/superpowers/specs/2026-08-12-domain-attach-design.md §3-§4. // // The attach splits on one question the console can answer: does the app // already have a ready route? Yes → an ALIAS claim naming that route, and // nothing else is written (the `route_aliases` row is derived box-side; // minting a second route row is the #129 socket-race / #148 bare-alias // class this design exists to avoid). No → a FIRST-ROUTE attach: the same // claim with an empty route, plus a declared static route record in #137's // vocabulary, which the provisioning reconciler materializes as the app's // real primary route. // // The invariant: the write order is CLAIM THEN RECORD. The claim's // create-only write IS the fleet-wide uniqueness check (#151's // domains.AttachClaim — the bucket key is the cross-account gate), so it // must be decided before anything else about the host exists. That ordering // also picks the failure worth having — a half-failed attach leaves at worst // a claim with no record (inert: no route is materialized from a // first-route claim, and detach or a retry clears it), never a live route // whose host is unclaimed. A record write that fails ambiguously therefore // unwinds both best-effort and refuses, while a record ALREADY at the key // (which, being account+app scoped, can only be this app's own) makes the // attach idempotent rather than a permanent 500. The reverse write order // would let two accounts each declare a record for one host and the loser // find out from the fleet. // // The fleet-wide deployment-route refusal lives in here for the same reason, // and with one extra rule: it sits IMMEDIATELY BEFORE each AttachClaim, below // every gate that could refuse for another reason. Byte-identical refusals // are only half of the neutrality — ORDER is the other half. A check placed // above attachChannel, say, is read apart from the ordinary "held by someone // else" 409 by a prober who deliberately sends a malformed channel: a // hostname a live box serves comes back 409, anything else comes back the // channel refusal, and the deployment's whole non-alias route set enumerates // one hostname at a time. So the two calls below are deliberately duplicated // rather than hoisted to the top of this function, and anything added to // either branch later belongs ABOVE them, never between them and the write. // (Task 5 review, rounds 1 and 2; the CLI door's own direct-AttachClaim path // carries the same guard under the same rule — apidomains.go.) // // Both refusals go through claimRefusal with domains.ErrHostUnavailable // rather than constructing a doorRefusal here, so they ARE the existing // refusal path rather than a second one that merely matches it today. // // audit + bumpSerial live in here rather than in the doors, so both doors // get them, in the right place, for free: strictly after the write they // announce — and gated on an ACTUAL write (#151's changed-gating rule): a // re-attach that stored nothing new must not send every box in the fleet // converging onto a serial that names no change, nor bury the tenant's // audit log under no-ops. package console import ( "context" "errors" "log" "net/http" "os" "sort" "strings" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // doorRefusal is a refusal the calling door translates into its own shape — // a rendered message for the browser, a JSON error (or, for 404, the // family's byte-identical http.NotFound) for the CLI API. Msg is // member-facing and NEVER names another account: the 409 below is one // string whoever holds the host, including the caller. type doorRefusal struct { Status int Msg string } // attachResult is what the doors report back. FirstRoute distinguishes the // two mechanisms — true means a declared record was written (or found // already standing) and a real route is materializing, which is the // difference between "your domain serves the app the moment DNS points" and // "a new route is being created". Created is domains.AttachClaim's own // answer: this call brought the claim into existence (false on a re-attach, // which the claim writer treats as a converge or a no-op, never a refusal). // // Pending is the #215 answer: the claim is stored but its writer has not yet // proved control of the hostname, so nothing serves it, and on the // first-route branch no record was declared. Verify is the TXT record that // proves it; re-attaching once it is published turns the claim verified. type attachResult struct { OperationID string OperationPhase string Claim domains.Claim Created bool FirstRoute bool // a declared record backs this attach Channel string // channel the first-route record follows ("" for alias) Pending bool Verify dnsRecord } // attachDomain claims host for acct's app and, when the app has no route to // alias, declares the static route that becomes its first one. kindFlag and // channelFlag may both be "" — the kind is then inferred (alias if there is // a route, static otherwise) and the channel is the one the host's own // existing route follows, or failing that the app's sole shipped channel. func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string, messages ...string) (attachResult, *doorRefusal) { return s.attachDomainAs(ctx, acct, app, host, kindFlag, channelFlag, principal, false, messages...) } // attachDomainAs is attachDomain with the platform door (#209) named. // platform true lets a hostPlatform name through and nothing else; the // CALLER has already established that principal is a platform operator // (handleAPIDomainAttach refuses the flag otherwise). The browser form never // passes it. func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string, platform bool, messages ...string) (attachResult, *doorRefusal) { message := "" if len(messages) != 0 { message = messages[0] } if ref := s.bucketRefusal(); ref != nil { return attachResult{}, ref } if msg, status := s.temporaryRefusal(acct, app, "attach a domain to it"); msg != "" { return attachResult{}, &doorRefusal{Status: status, Msg: msg} } // Policy first: shape and platform space are decided before any write, // so a refused host never reaches a bucket key. Both checks are #151's // exported validation surface (internal/domains + domainHostDenied), the // same pair the CLI API door runs — one validation spot, two doors. host, ok := domains.NormalizeAliasHost(host) if !ok { return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, "not a valid hostname — attach a full hostname like tickets.example.com"} } if policy := s.domainHostPolicy(host, acct.Sqid); policy == hostReserved || (policy == hostPlatform && !platform) { return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, "that hostname is platform-managed"} } // Plans 3a: custom domains need a plan that includes them. if ref := s.customDomainRefusal(acct, host); ref != nil { return attachResult{}, ref } // The reserved sidecar suffix, refused at the same policy stage and with // the same status as the two checks above: this door must never mint a // claim (let alone a route record) under a name provision.SidecarSuffix // reserves. Defence in depth behind Task 3's structural refusal to SERVE // a Kind=="sidecar" row (edge.go's Reload) — this closes the other // direction, a member attaching an unrelated app's domain under it. if strings.HasSuffix(host, provision.SidecarSuffix) { return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, "that hostname is reserved for sidecar processes"} } route, selfChannel, err := s.readyRouteHost(acct.Sqid, app, host) if err != nil { log.Printf("console: attach %s: registry: %v", host, err) return attachResult{}, &doorRefusal{http.StatusInternalServerError, "could not read this deployment's routes — try again in a moment"} } claim := domains.Claim{ Schema: 1, Host: host, Account: acct.Sqid, App: app, Route: route, CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedBy: principal, } // The claim write asks for proof of control (#215) through prove: a TXT // lookup, made only when the answer can change what is written (see // domains.AttachClaimProven), so a re-attach of a verified claim costs no // DNS round trip. prove := s.domainProver(ctx, host, acct.Sqid) verify := s.domainChallenge(host, acct.Sqid) // --- alias attach: the app already has a route to serve from --------- if route != "" { if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil { return attachResult{}, ref } created, changed, pending, err := s.attachDomainClaim(ctx, acct, app, claim, time.Now(), prove) if err != nil { return attachResult{}, claimRefusal(host, err) } if changed { s.auditActivity(acct, app, principal, Activity{ Action: "domain-attach", TargetType: "instance", Target: route, Message: message, Detail: host + " → " + route + platformNote(platform) + pendingNote(pending), }) // A pending claim is invisible to every box, so writing one // gives the fleet nothing to converge on. if !pending { s.bumpSerial(ctx) } } return attachResult{Claim: claim, Created: created, Pending: pending, Verify: verify}, nil } if s.directAppsDomainHost(host) { return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, hostUnavailableMsg} } if s.firstRouteOutsideNamespace(host, acct.Sqid) { return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, "that hostname is platform-managed"} } // --- first-route attach: this host becomes the app's primary -------- if kindFlag != "" && kindFlag != "static" { return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, "an app with no running route can only attach a static domain — deploy first, then attach"} } channel, ref := s.attachChannel(ctx, acct.Sqid, app, channelFlag, selfChannel) if ref != nil { return attachResult{}, ref } // Plans 3a: a first route starts a running thing, so it meets the cap, // above heldElsewhere per the ordering rule. defer s.planLock(acct)() if err := s.checkRunningThings(ctx, acct, 1); err != nil { if isPlanCap(err) { return attachResult{}, &doorRefusal{http.StatusConflict, err.Error()} } return attachResult{}, &doorRefusal{http.StatusBadGateway, "could not create the instance — try again"} } if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil { return attachResult{}, ref } if s.cfg.HostClaim { return s.attachClaimFirstRoute(ctx, acct, app, host, channel, principal) } created, changed, pending, err := s.attachDomainClaim(ctx, acct, app, claim, time.Now(), prove) if err != nil { return attachResult{}, claimRefusal(host, err) } if pending { // No record until the claim is proven. The record is what makes the // host a real route, served and certified on whatever box adopts it, // so declaring it now would serve an unproven name, which is the // takeover #215 closes. Re-attaching once the TXT record is // published lands here verified and declares it then. if changed { s.auditActivity(acct, app, principal, Activity{ Action: "domain-attach", TargetType: "instance", Target: host, Message: message, Detail: host + " (first route, " + channel + ")" + pendingNote(true), }) } return attachResult{Claim: claim, Created: created, Channel: channel, Pending: true, Verify: verify}, nil } rec := provision.Record{ Schema: 1, Host: host, App: app, Account: acct.Sqid, Channel: channel, Kind: "static", CreatedAt: time.Now().UTC(), CreatedBy: principal, } if err := provision.CreateRecord(ctx, s.cfg.Releases, rec); err != nil { // A record already at this key is NOT a failure to undo. The key is // account+app scoped, so a create that lost the race lost it to this // app's own record — a retry after a half-failed attach, or a record // written for a host whose claim had been released. GetRecord // re-reads it and refuses a body that disagrees with its own path, // so a successful read is proof the record describes this app and // account; the attach is then idempotent, the claim stays (this // app's, whether this call minted it or an earlier one did), and the // truth reported back is the record's channel, not the one this call // would have written. audit + serial follow the write-gating rule: // only when the CLAIM half actually wrote — a fully-converged // re-attach (claim untouched, record standing) stores nothing and // announces nothing. if errors.Is(err, release.ErrCASConflict) { existing, gerr := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host) if gerr == nil && attachWrote(*existing) { if changed { s.auditActivity(acct, app, principal, Activity{ Action: "domain-attach", TargetType: "instance", Target: host, Message: message, Detail: host + " (first route, " + existing.Channel + ", record already present)", }) s.bumpSerial(ctx) } return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: existing.Channel, Verify: verify}, nil } // Unreadable (malformed, or a body that lies about its // identity) or written by a door other than this one. Not ours // to delete and not safe to write over, so the claim goes back // (only if this call minted it — a pre-existing claim is not // this failure's to unwind) and the member is told what stands // in the way — the fact of a record, and nothing about who // wrote it. log.Printf("console: attach %s: existing record is not this door's to adopt (read: %v)", host, gerr) s.rollbackAttach(ctx, acct, app, host, false, created) return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, "a route record already exists for this host — detach it or pick another hostname"} } // Anything else is AMBIGUOUS: a timeout or a dropped connection can // come back as an error for a write that landed. Unwind both, in // detach's order (record, then claim), so the failure cannot leave a // record behind a released claim — a route materializing for a host // anyone may now claim. log.Printf("console: attach %s: record write: %v", host, err) s.rollbackAttach(ctx, acct, app, host, true, created) return attachResult{}, &doorRefusal{http.StatusInternalServerError, "could not declare the route for this domain — nothing was attached, try again"} } // The record wrote (a real change by definition), so this announces // regardless of whether the claim half was the no-op of a retry. s.auditActivity(acct, app, principal, Activity{ Action: "domain-attach", TargetType: "instance", Target: host, Message: message, Detail: host + " (first route, " + channel + ")", }) s.bumpSerial(ctx) return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: channel, Verify: verify}, nil } // detachDomain releases host's claim and, when the claim minted the app's // own route (empty Route — the first-route case), removes the declared // record first so the reconciler's removal phase drops the route. // // Every non-answer is one refusal: absent, another account's, or this // account's other app's all return the same 404, which the doors render as // http.NotFound — a detach must not be the probe that confirms a host is // claimed, or by whom. func (s *Server) detachDomain(ctx context.Context, acct *Account, app, host, principal string) *doorRefusal { if ref := s.bucketRefusal(); ref != nil { return ref } // The browser twin of handleAPIDomainDetach's control-plane refusal, and // it has to be here rather than in the door above it: this core is what // deletes the provision record, so a guard the browser path did not // share would leave the same route retirable by the same mechanism // through a form post. Refused outright for a flagged app, for the // reasons the API door's comment gives, and BEFORE the claim is read — // its own caller (handleAppDomainDetach) has already spent the session, // CSRF and owner gates, so a non-member never reaches this sentence. if msg, status := s.controlPlaneRefusal(acct, app, "detached"); msg != "" { return &doorRefusal{status, msg} } notAttached := &doorRefusal{http.StatusNotFound, "not attached to this app"} // A host that is not even a hostname cannot be a claim key: refuse it // here rather than handing it to a store as a key segment. host, ok := domains.NormalizeAliasHost(host) if !ok { return notAttached } // A platform hostname's claim is the operator's to release (#209). // Decided from the name alone, before the claim is read, so it says // nothing about who holds it. Reserved names are left alone: nobody can // claim one now, and a member holding one from before it was reserved // must still be able to let it go. if s.domainHostPolicy(host, acct.Sqid) == hostPlatform && !s.platformOperator(principal) { return &doorRefusal{http.StatusForbidden, platformOperatorMsg} } claim, err := domains.GetClaim(ctx, s.cfg.Releases, host) if err != nil { if errors.Is(err, os.ErrNotExist) { return notAttached } log.Printf("console: detach %s: claim read: %v", host, err) return &doorRefusal{http.StatusInternalServerError, "could not read this domain's claim — try again in a moment"} } if claim.Account != acct.Sqid || claim.App != app { return notAttached } if claim.Route == "" { // First-route: the record goes before the claim, the mirror of // attach. A record left behind by a released claim would keep a // route alive for a host anyone may now claim. if err := s.retireInstanceRecord(ctx, acct.Sqid, app, host, "domain-detach", claim.RecordIncarnation); err != nil { // The record's delete operation is durable and still running // (or another operation holds the app): the hostname stays // claimed until a later detach finds the record gone. if accepted, ok := claimRetireAcceptedOf(err); ok { s.audit(acct, app, principal, "domain-detach", "removal of the route for "+host+" accepted, operation "+accepted.operation) s.bumpSerial(ctx) return &doorRefusal{http.StatusConflict, claimDetachInProgressMsg} } if errors.Is(err, hostops.ErrPending) { return &doorRefusal{http.StatusConflict, claimRetireBusyMsg} } log.Printf("console: detach %s: record delete: %v", host, err) return &doorRefusal{http.StatusInternalServerError, "could not remove this domain's route — it is still attached, try again"} } } // DetachClaim re-checks ownership before deleting (its own neutrality); // the read above already proved it, so removed=false here can only mean // a race this call lost — in which case nothing was written and nothing // announces. removed, err := s.detachDomainClaim(ctx, host, acct.Sqid, app) if msg, ok := claimAliasRefusal(err); ok { return &doorRefusal{http.StatusConflict, msg} } if errors.Is(err, hostops.ErrContract) { return &doorRefusal{http.StatusConflict, err.Error()} } if err != nil { log.Printf("console: detach %s: claim delete: %v", host, err) return &doorRefusal{http.StatusInternalServerError, "could not release this domain's claim — try again in a moment"} } if removed { s.audit(acct, app, principal, "domain-detach", host) s.bumpSerial(ctx) } return nil } // readyRouteHost answers "does this app already have a route to alias?" with // the host of the first one, or "" for none. The selection is // domains.firstReadyUpstream's, minus the upstream copy: only the HOST is // needed here, because nothing about the primary's upstream is copied — // the alias resolves to it at request time, box-side. // // An empty Registry path is "no routes", not an error: a console configured // without a registry (and every test that wants the first-route branch) has // nothing to alias, and a missing DB must not be reported as a route. // // attaching is the host being attached. It is never an alias candidate — a // claim whose route names its own host is a self-alias, which is not a thing // the box side can resolve — and its ready row is instead reported back as // selfChannel, the channel that row already follows. That case is real: an // operator-added custom-domain route (story.carlosframework.com, spec §7) // that no claim covers yet. Skipping it as an alias lands the attach on the // first-route branch, where the declared record describes the row that // already exists and #137's adopt-on-match transfers ownership with no box // command — the backfill, arriving through the member's own door — and // carrying its channel forward is what keeps that adoption from being a // silent repoint (provision.convergeOwned writes the record's channel onto // the row it adopts). func (s *Server) readyRouteHost(sqid, app, attaching string) (alias, selfChannel string, err error) { if s.cfg.Registry == "" { return "", "", nil } reg, err := openRegistry(s.cfg.Registry) if err != nil { return "", "", err } routes, err := reg.List() // host order if err != nil { return "", "", err } for _, rt := range routes { if rt.App != app || release.RouteAccount(rt) != sqid { continue } // A platform mint is not a source to alias — aliasing a mint is // self-reference, the same rule firstReadyUpstream carries — and a // route that is not ready has nothing to serve yet. if rt.Platform || rt.Status != "ready" { continue } // Nor a sidecar, which is not a source to alias for a blunter // reason: nothing can dial one. This says explicitly what // firstReadyUpstream gets for free from the clause this function // drops — it requires an upstream (Socket/Addr, or a channel for a // static), and a sidecar is exactly the row that has neither. Every // other test here passes a sidecar: it is the app's, it is the // account's, it is not Platform, and it is ready, because ready is // what makes it runnable. Aliasing onto one would mint a claim the // box refuses for good (ReconcileAliases' own c.Route check), so the // member's domain would never serve and nothing would say why. if rt.IsSidecar() { continue } if rt.Host == attaching { selfChannel = rt.Channel continue } if alias == "" { alias = rt.Host } } return alias, selfChannel, nil } // attachChannel resolves the channel a first-route record follows: the flag // when given, then the channel the host's own existing route already // follows, then the app's sole shipped channel. Nothing shipped and several // channels are both refusals rather than guesses — a record pointed at the // wrong channel serves the wrong build from a domain the member just // published. // // selfChannel (the backfill case above) takes precedence over the listing // for two reasons: the record is about to be adopted onto that live row, so // naming any other channel would REPOINT a running route the member did not // ask to move, and an app with two channels would otherwise be refused as // ambiguous for a domain whose channel was never in question. func (s *Server) attachChannel(ctx context.Context, sqid, app, channelFlag, selfChannel string) (string, *doorRefusal) { // Shape only, never existence: a channel a member is about to ship is a // legitimate target, but a malformed one becomes a bucket key segment // and a route pinned to nothing (release.ValidChannel is the same rail // the instance-create door puts on its own channel field). if channelFlag != "" { if !release.ValidChannel(channelFlag) { return "", &doorRefusal{http.StatusUnprocessableEntity, "invalid channel name"} } return channelFlag, nil } if selfChannel != "" && release.ValidChannel(selfChannel) { return selfChannel, nil } prefix := release.ChannelsPrefix(sqid, app) keys, err := s.cfg.Releases.List(ctx, prefix) if err != nil { log.Printf("console: attach: list channels %s/%s: %v", sqid, app, err) return "", &doorRefusal{http.StatusInternalServerError, "could not list this app's channels — try again in a moment"} } var names []string for _, k := range keys { name, ok := strings.CutSuffix(strings.TrimPrefix(k, prefix), ".json") if !ok || name == "" { continue } names = append(names, name) } sort.Strings(names) switch len(names) { case 0: return "", &doorRefusal{http.StatusUnprocessableEntity, "nothing shipped yet — carlos ship first"} case 1: return names[0], nil } return "", &doorRefusal{http.StatusUnprocessableEntity, "this app follows several channels (" + strings.Join(names, ", ") + ") — say which one this domain serves"} } // attachWrote reports whether rec is a record THIS door could have written: // the fixed shape of a first-route attach — kind static, no slug, and the // shared pool and unconstrained region (§3). It is the guard on the // idempotent conflict branch, and it is narrow on purpose. The record key // is host-scoped within an app, and an app with a custom manifest Domain // can have `carlos instances create` write an INSTANCE record at the very // same key; adopting one of those as "an attach that already happened" // would hand detach the authority to retire a tenant instance this door // never created. Anything that fails this test is left alone and refused. func attachWrote(rec provision.Record) bool { return rec.Kind == "static" && rec.Slug == "" && rec.Region == "" && rec.Pool == "" } // bucketRefusal is the "this console cannot answer at all" check both verbs // open with: a deployment with no bucket has nowhere to put a claim or a // record. Same 503 and the same sentence the instance API's gate uses // (apiinstances.go), so one deployment shape produces one answer wherever a // member meets it. func (s *Server) bucketRefusal() *doorRefusal { if s.cfg.Releases == nil { return &doorRefusal{http.StatusServiceUnavailable, "no deployment bucket on this host — the console is running without one"} } return nil } // rollbackAttach unwinds a half-written first-route attach, best effort: // the bucket that just refused a write may refuse these too, and a member // waiting on a refusal must not also wait on a retry loop. dropRecord asks // for the record to go first — set when the record write's outcome is // UNKNOWN (a timeout can report failure for an object that landed), and // clear when the record is known to be someone else's business. dropClaim // is whether the claim is this failure's to release at all: only when THIS // call created it — deleting a claim an earlier attach minted would strand // a host the member still legitimately holds. DetachClaim is idempotent and // owner-checked, so a no-op costs one read. // // A rollback that itself fails leaves a claim with no record: inert (nothing // materializes from a claim alone), visible to its own owner in the app's // domain list, and clearable with detach. That is the failure this ordering // was chosen to have. func (s *Server) rollbackAttach(ctx context.Context, acct *Account, app, host string, dropRecord, dropClaim bool) { if s.cfg.HostClaim { return } if dropRecord { if err := s.retireInstanceRecord(ctx, acct.Sqid, app, host, "attach-rollback", ""); err != nil { log.Printf("console: attach %s: record rollback: %v", host, err) } } if !dropClaim { return } if _, err := s.detachDomainClaim(ctx, host, acct.Sqid, app); err != nil { log.Printf("console: attach %s: claim rollback: %v (claim is stranded — detach clears it)", host, err) } } // claimRefusal maps a lost claim write onto the door's answer. The 409 is // ONE string whoever holds the host: domains.ErrHostUnavailable already // refuses to name the holder, and the text here is its message verbatim — // the same bytes the CLI API door answers, so the two doors cannot be // diffed against each other to learn anything either one refuses to say. func claimRefusal(host string, err error) *doorRefusal { if msg, ok := claimAliasRefusal(err); ok { return &doorRefusal{http.StatusConflict, msg} } if errors.Is(err, hostops.ErrPending) { return &doorRefusal{http.StatusConflict, claimRetireBusyMsg} } if errors.Is(err, hostops.ErrContract) { return &doorRefusal{http.StatusConflict, err.Error()} } if errors.Is(err, domains.ErrHostUnavailable) { return &doorRefusal{http.StatusConflict, hostUnavailableMsg} } log.Printf("console: attach %s: claim write: %v", host, err) return &doorRefusal{http.StatusInternalServerError, "could not claim this domain — try again in a moment"} } // platformNote marks an audit row written through the platform door (#209), // so the holding account's activity log says the operator placed the claim. func platformNote(platform bool) string { if platform { return " (platform operator)" } return "" } // pendingNote marks an audit row for a claim written pending (#215), so the // account's activity log does not read as though the hostname went live. func pendingNote(pending bool) string { if pending { return " (waiting for DNS verification)" } return "" }