ecosystem: let the client layer read correlation IDs, never mint them

setEcosystemHeaders minted a fresh correlation ID whenever the context
carried none. Every hop of one action therefore got a different ID, so
a trace could not be followed from resolve to attention to execute.
The header layer now only reads what the caller assigned. Praxis
requests are typed the same way Nexus ones already were, so a 401 from
Praxis reports as unauthorized instead of a generic failure, and a
"resolved" response with no entity is an error rather than a silent
empty result. Hexis refuses to wire at all when a token is configured,
because the vendored client cannot send one and starting anyway would
send unauthenticated calls under the belief they were authenticated.

Found in review of #84.
This commit is contained in:
kami
2026-08-01 14:14:19 +04:00
parent 5aaecd2a53
commit f432eb0b25
+72 -55
View File
@@ -45,6 +45,12 @@ const mavenRequester = "maven"
// setEcosystemHeaders stamps the version, requester, auth and correlation
// headers common to every outgoing ecosystem request. token may be empty,
// which means the transport itself is trusted (loopback or unix socket).
//
// The correlation ID is read from the context and never minted here. Minting
// one per request sent the far side an ID that existed nowhere on this side,
// and gave a single multi-hop action as many unrelated IDs as it made calls.
// Callers that start an action assign the ID once (handleHexisAct,
// handlePraxisAct, resolveEntityReference) and every hop inherits it.
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader, token string) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set(versionHeader, ecosystemAPIVersion)
@@ -53,13 +59,9 @@ func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader,
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
id := correlationIDFromCtx(ctx)
if id == "" {
// A call made outside a traced action still gets an ID, so the far
// side's log line can be matched to this one request.
id = newCorrelationID()
if id := correlationIDFromCtx(ctx); id != "" {
req.Header.Set("X-Correlation-ID", id)
}
req.Header.Set("X-Correlation-ID", id)
}
// ecosystemError is the typed failure every ecosystem client returns, so
@@ -202,16 +204,16 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
func (c *nexusClient) Health(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
if err != nil {
return err
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
}
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
}
resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("nexus health: %s", http.StatusText(resp.StatusCode))
return httpError("nexus", "health", resp.StatusCode)
}
return nil
}
@@ -237,8 +239,11 @@ func (c *praxisClient) withToken(token string) *praxisClient {
return c
}
// getJSON performs a GET and decodes the JSON body into out.
func (c *praxisClient) getJSON(ctx context.Context, path string, out any) error {
// getJSON performs a GET and decodes the JSON body into out. op is the logical
// operation name for errors and traces: the path carries the query string, and
// after entity scoping that means an entity id in every log line built from the
// error, next to a trace that redacts far less than that.
func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return err
@@ -246,21 +251,21 @@ func (c *praxisClient) getJSON(ctx context.Context, path string, out any) error
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return &ecosystemError{Service: "praxis", Op: path, Err: err}
return &ecosystemError{Service: "praxis", Op: op, Err: err}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return httpError("praxis", path, resp.StatusCode)
return httpError("praxis", op, resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return &ecosystemError{Service: "praxis", Op: path, Status: resp.StatusCode, Err: err}
return &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
}
return nil
}
func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) {
var out []map[string]any
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
return out, err
}
@@ -270,13 +275,14 @@ func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[stri
// instead of filtering the unscoped list client-side.
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) ([]map[string]any, error) {
var out []map[string]any
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
err := c.getJSON(ctx, "attention_for_entity",
fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
return out, err
}
func (c *praxisClient) ListChanges(ctx context.Context, limit int) ([]map[string]any, error) {
var out []map[string]any
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out)
err := c.getJSON(ctx, "changes", fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out)
return out, err
}
@@ -302,24 +308,32 @@ type praxisItem struct {
// postItemAction posts {"item_id": id} to a Praxis tools lifecycle endpoint
// and decodes the resulting item. Shared by Surface/Acknowledge/Resolve/Ignore.
func (c *praxisClient) postItemAction(ctx context.Context, path, itemID string) (*praxisItem, error) {
body, _ := json.Marshal(map[string]any{"item_id": itemID})
func (c *praxisClient) postItemAction(ctx context.Context, op, path, itemID string) (*praxisItem, error) {
return c.postJSON(ctx, op, path, map[string]any{"item_id": itemID})
}
// postJSON posts a body to a Praxis lifecycle endpoint and decodes the item.
// Every failure is a *ecosystemError, including the transport and decode ones:
// these are the paths that mutate remote state, and the question worth
// answering afterwards is whether the call never left or was refused.
func (c *praxisClient) postJSON(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
}
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, httpError("praxis", path, resp.StatusCode)
return nil, httpError("praxis", op, resp.StatusCode)
}
var out praxisItem
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode: %w", err)
return nil, &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
}
return &out, nil
}
@@ -328,46 +342,28 @@ func (c *praxisClient) postItemAction(ctx context.Context, path, itemID string)
// ECOSYSTEM-SPEC.md §2.3). Callers that read attention aloud must call this, never
// Acknowledge, so "I mentioned it" stays distinguishable from "you told me you saw it".
func (c *praxisClient) Surface(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "/api/v1/tools/surface", itemID)
return c.postItemAction(ctx, "surface", "/api/v1/tools/surface", itemID)
}
func (c *praxisClient) Acknowledge(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "/api/v1/tools/acknowledge", itemID)
return c.postItemAction(ctx, "acknowledge", "/api/v1/tools/acknowledge", itemID)
}
func (c *praxisClient) Resolve(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "/api/v1/tools/resolve", itemID)
return c.postItemAction(ctx, "resolve", "/api/v1/tools/resolve", itemID)
}
func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "/api/v1/tools/ignore", itemID)
return c.postItemAction(ctx, "ignore", "/api/v1/tools/ignore", itemID)
}
func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) {
body, _ := json.Marshal(map[string]any{"item_id": itemID, "pinned": pinned})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/tools/pin", bytes.NewReader(body))
if err != nil {
return nil, err
}
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, httpError("praxis", "pin", resp.StatusCode)
}
var out praxisItem
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &out, nil
return c.postJSON(ctx, "pin", "/api/v1/tools/pin", map[string]any{"item_id": itemID, "pinned": pinned})
}
func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem, error) {
var out praxisItem
err := c.getJSON(ctx, "/api/v1/tools/items/"+itemID, &out)
err := c.getJSON(ctx, "get_item", "/api/v1/tools/items/"+itemID, &out)
if err != nil {
return nil, err
}
@@ -376,7 +372,7 @@ func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem,
func (c *praxisClient) Search(ctx context.Context, query string, limit int) ([]praxisItem, error) {
var out []praxisItem
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out)
err := c.getJSON(ctx, "search", fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out)
return out, err
}
@@ -400,14 +396,18 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
// Hexis capability service
if cfg.Hexis != nil && cfg.Hexis.URL != "" {
w.hexis = hexisclient.New(cfg.Hexis.URL)
if cfg.Hexis.Token != "" {
// Upstream hexis grew Client.WithToken, but the copy vendored
// here predates it, so the token cannot be sent yet. Say so
// loudly rather than pretending the call is authenticated.
log.Printf("ecosystem: hexis token configured but the vendored hexis client cannot send it — re-vendor github.com/kami/hexis to enable bearer auth")
// Upstream hexis grew Client.WithToken, but the copy vendored here
// predates it, so the token cannot be sent. Hexis is the only one
// of the three that executes anything, and configuring auth on the
// executing service is not a best-effort request: refuse to wire it
// rather than execute unauthenticated for weeks behind one boot-time
// log line.
log.Printf("ecosystem: hexis token configured but the vendored hexis client cannot send it — hexis stays disabled; re-vendor github.com/kami/hexis to enable bearer auth")
} else {
w.hexis = hexisclient.New(cfg.Hexis.URL)
log.Printf("ecosystem: hexis at %s", cfg.Hexis.URL)
}
log.Printf("ecosystem: hexis at %s", cfg.Hexis.URL)
} else {
log.Printf("ecosystem: hexis not configured")
}
@@ -439,7 +439,19 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin
log.Printf("ecosystem: nexus resolve error: %v", err)
return "", "", nil, err
}
if result.Status == "resolved" && result.Entity != nil {
if result.Status == "resolved" {
// "resolved" with nothing to resolve to is a contract violation, not a
// miss. Treating it as "no such entity" let the caller fall straight
// through to the local executor with his verb intact, which is a
// dependency failure reaching execution.
if result.Entity == nil || result.Entity.ID == "" {
err := &ecosystemError{
Service: "nexus", Op: "resolve", Status: 200,
Err: errors.New("resolved status with no entity"),
}
log.Printf("ecosystem: %v", err)
return "", "", nil, err
}
return result.Entity.ID, result.Entity.DisplayName, nil, nil
}
if result.Status == "ambiguous" {
@@ -459,6 +471,11 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin
// healthy and genuinely has nothing registered for this entity. Callers must
// not conflate the two: a dependency failure must not silently read as "no
// capabilities" and fall through to unrelated local execution.
//
// The vendored Hexis client stamps a correlation header on Execute only, so
// discovery and execution cannot be joined on the Hexis side. Maven's own
// traces still share one ID for both hops; the gap is on the far side and
// closes when the client is re-vendored.
func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID string) ([]hexisclient.Capability, error) {
if w == nil || w.hexis == nil || entityID == "" {
return nil, nil