Skip to content

/deployments only returns top-level deployments — subdeployments not discoverable without parent ID #8

@Sam-Bolling

Description

@Sam-Bolling

Finding

DeploymentRepository.applyFilters() explicitly excludes subdeployments from top-level GET /deployments responses with WHERE parent_deployment_id IS NULL OR parent_deployment_id = '', making subdeployments undiscoverable without prior knowledge of the parent deployment ID.

Review Source: Live integration testing against the Go CSAPI server during OSHConnect-Python publisher fleet migration to the Go server.
Severity: P2-Important
Category: API Design
Ownership: Upstream (SomethingCreativeStudios/connected-systems-go)


Problem Statement

When a client requests GET /deployments, the repository layer explicitly filters to only top-level deployments (parent_deployment_id IS NULL). Subdeployments created via POST /deployments/{parentId}/subdeployments are completely absent from the top-level listing. The only way to discover them is to already know the parent deployment ID and query /deployments/{parentId}/subdeployments.

This makes it impossible for a client to enumerate all deployments in a collection without first fetching every top-level deployment and recursively querying each for subdeployments.

Affected code — internal/repository/deployment_repository.goapplyFilters():

func (r *DeploymentRepository) applyFilters(query *gorm.DB, params *queryparams.DeploymentsQueryParams, parentId *string) *gorm.DB {
    // ... other filters ...

    if parentId != nil {
        if params.Recursive {
            // Recursive traversal from parent to include all descendants.
            query = query.Where(`id IN (
                WITH RECURSIVE deployment_descendants AS (
                    SELECT id FROM deployments WHERE parent_deployment_id = ?
                    UNION ALL
                    SELECT d.id FROM deployments d
                    JOIN deployment_descendants dd ON d.parent_deployment_id = dd.id
                )
                SELECT id FROM deployment_descendants
            )`, *parentId)
        } else {
            // Direct children only
            query = query.Where("parent_deployment_id = ?", *parentId)
        }
    } else {
        if params.Recursive {
            // Canonical recursive search should include top-level deployments and all descendants.
            // ← Currently a no-op — does NOT actually include descendants
        } else {
            // ← THIS LINE: explicitly excludes all subdeployments from top-level listing
            query = query.Where("parent_deployment_id IS NULL OR parent_deployment_id = ''")
        }
    }

    return query
}

Affected code — internal/api/deployment_handler.goListDeployments():

func (h *DeploymentHandler) ListDeployments(w http.ResponseWriter, r *http.Request) {
    params := queryparams.DeploymentsQueryParams{}.BuildFromRequest(r)
    deployments, total, err := h.repo.List(params, nil)  // parentId = nil → top-level only
    // ...
}

Scenario:

# Create a top-level deployment
POST /deployments
{ "type": "Feature", "properties": { "uid": "urn:test:dep:parent", "name": "Parent" } }
# → 201, Location: /deployments/aaa-111

# Create a subdeployment
POST /deployments/aaa-111/subdeployments
{ "type": "Feature", "properties": { "uid": "urn:test:dep:child", "name": "Child" } }
# → 201, Location: /deployments/bbb-222

# List all deployments
GET /deployments
# Expected: both "Parent" and "Child" returned
# Actual: only "Parent" returned — "Child" is invisible

# Also note: the `recursive=true` path for parentId==nil is a no-op:
GET /deployments?recursive=true
# Expected: all deployments (top-level + descendants)
# Actual: same as without recursive — the `if params.Recursive` branch does nothing

Impact: The OSHConnect-Python bootstrap helpers use find_by_uid() against /deployments to check if a subdeployment already exists before creating it. Because subdeployments are not in the top-level listing, the lookup always returned None, causing duplicate creation attempts that failed on the unique UID constraint. The workaround adds special-case logic to search under the parent:

# Workaround in bootstrap_helpers.py
def ensure_deployment(session, base_url, collection_id, deployment, parent_id=None):
    existing = find_by_uid(session, deps_url, deployment["properties"]["uid"])
    if existing:
        return existing["id"]
    # Subdeployment not in top-level listing — try under parent
    if parent_id:
        sub_url = f"{deps_url}/{parent_id}/subdeployments"
        existing = find_by_uid(session, sub_url, deployment["properties"]["uid"])
        if existing:
            return existing["id"]
    # ... create it

Additionally, the recursive=true code path for top-level listings is a no-op comment with no actual implementation:

if params.Recursive {
    // Canonical recursive search should include top-level deployments and all descendants.
    // ← This is just a comment — no filter is applied, so it returns same as non-recursive
}

Ownership Verification

This code is pre-existing in the upstream SomethingCreativeStudios/connected-systems-go repository. The OS4CSAPI fork is currently synced with upstream (main branch is up to date).

Conclusion: This code is upstream.

Files to Modify

File Action Est. Lines Purpose
internal/repository/deployment_repository.go Modify ~15 Change default listing to include subdeployments (flat view), or implement recursive=true at top level
e2e/ Modify ~25 Add E2E test verifying subdeployments appear in top-level listing

Proposed Solutions

Option A: Flat listing — include all deployments at top level (Recommended)

Remove the parent_deployment_id IS NULL filter from the default (non-recursive, no-parent) path. All deployments appear in GET /deployments regardless of nesting depth. Each subdeployment carries its parent_deployment_id so clients can reconstruct the hierarchy if needed.

} else {
    if params.Recursive {
        // No filter needed — return everything
    } else {
        // REMOVED: query = query.Where("parent_deployment_id IS NULL OR parent_deployment_id = ''")
        // Now returns all deployments, matching SensorHub behavior
    }
}

Pros: Matches SensorHub behavior; simplest fix; clients can discover all deployments with a single request; consistent with how /systems returns all systems (including subsystems)
Cons: Changes existing behavior — clients relying on top-level-only might receive more results
Effort: Small | Risk: Low

Option B: Implement recursive=true at top level

Keep the default behavior (top-level only) but implement the currently-no-op recursive=true path:

} else {
    if params.Recursive {
        // Return all deployments (top-level + all descendants)
        // No WHERE clause needed — just omit the parent filter
    } else {
        query = query.Where("parent_deployment_id IS NULL OR parent_deployment_id = ''")
    }
}

Pros: Preserves existing default behavior; adds opt-in flat listing; the code comment already suggests this was intended
Cons: Clients must know to pass ?recursive=true; still breaks discovery for clients that don't know about the parameter
Effort: Small | Risk: None

Scope — What NOT to Touch

  • ❌ Do NOT modify files outside the "Files to Modify" table above
  • ❌ Do NOT refactor adjacent code that isn't part of this finding
  • ❌ Do NOT change the ListSubdeployments() handler behavior — it correctly lists children of a specific parent
  • ❌ Do NOT change the AddSubdeployment() creation flow
  • ❌ Do NOT modify the closure table logic in findAllChildren()

Acceptance Criteria

  • GET /deployments returns both top-level deployments and subdeployments (Option A), or GET /deployments?recursive=true returns all deployments (Option B)
  • Subdeployments include their parent_deployment_id in the response so clients can reconstruct hierarchy
  • GET /deployments/{id}/subdeployments still works correctly (direct children of a parent)
  • Pagination (limit, offset) works correctly with the expanded result set
  • Existing tests still pass (make test)
  • E2E test added verifying subdeployment visibility

Dependencies

Blocked by: Nothing
Blocks: Nothing
Related: #7?uid= filter ignored (compounds this problem — can't filter subdeployments by UID even if listed); #9 — Default limit of 10 (expanded listing increases the importance of reasonable pagination defaults)


Operational Constraints

⚠️ MANDATORY: Before starting work on this issue, review docs/governance/AI_OPERATIONAL_CONSTRAINTS.md if available.

Key constraints:

  • Precedence: OGC specifications → AI Collaboration Agreement → This issue description → Existing code → Conversational context
  • No scope expansion: Fix the finding, nothing more
  • Minimal diffs: Prefer the smallest change that satisfies the acceptance criteria
  • Ask when unclear: If intent is ambiguous, stop and ask for clarification
  • Respect ownership: This code is upstream — coordinate with the maintainer if contributing back

Ownership-Specific Constraints

If Upstream:

  • Track the issue for potential future contribution or discussion with the maintainer
  • If the fix is trivial and clearly beneficial, note in the issue that it could be offered as a separate upstream PR

References

# Document What It Provides
1 internal/repository/deployment_repository.goapplyFilters() Root cause — WHERE parent_deployment_id IS NULL excludes subdeployments
2 internal/api/deployment_handler.goListDeployments() Handler passes nil parentId → triggers top-level-only filter
3 OGC 23-001 §8.5 — Deployment resources Spec defines deployment listing endpoint
4 OSHConnect-Python publishers/bootstrap_helpers.pyensure_deployment() Real-world workaround demonstrating impact
5 SensorHub API behavior Comparison — SensorHub returns all deployments (flat) at /deployments

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions