Skip to content

feat(server): natural-key endpoints for target controls (7/7)#194

Draft
abhinav-galileo wants to merge 2 commits intoabhi/rfc-1-1-pr6-tenant-enforcementfrom
abhi/rfc-1-1-pr7-natural-key-attach
Draft

feat(server): natural-key endpoints for target controls (7/7)#194
abhinav-galileo wants to merge 2 commits intoabhi/rfc-1-1-pr6-tenant-enforcementfrom
abhi/rfc-1-1-pr7-natural-key-attach

Conversation

@abhinav-galileo
Copy link
Copy Markdown
Collaborator

Summary

Adds two write-path endpoints so client-facing consumers can enable or disable a control on a target using the natural identity (target_type, external_id) without ever holding Agent Control's internal integer target_id:

  • PUT /api/v1/targets/{target_type}/{external_id}/controls/{control_id}
  • DELETE /api/v1/targets/{target_type}/{external_id}/controls/{control_id}

Stacks on PR6. Part of the RFC 1.1 series.

Semantics

PUT (desired-state attach)

  • Lazily creates the target row with empty metadata if it doesn't exist in the caller's tenant
  • Upserts the (target, control) attachment to the requested enabled flag (defaults true)
  • Returns 200 with the current attachment summary
  • Logs at INFO on lazy target creation

DELETE (idempotent detach)

  • Returns 204 whether the attachment existed and was removed, never existed, or the target row itself doesn't exist in the caller's tenant
  • Final-state semantics: tell the server "make sure this attachment is gone" and it is

Cross-tenant non-disclosure

  • A control that exists only in another tenant returns the same 404 CONTROL_NOT_FOUND as a control that doesn't exist anywhere
  • A target with the same natural key in another tenant is treated as independent; PUT creates a new row in the caller's tenant

Path-safe identifier contracts

New constrained-string aliases in agent_control_models.target:

  • TargetTypeStr^[a-z][a-z0-9_]{0,63}$ (slug: lowercase, starts with letter)
  • ExternalIdStr^[A-Za-z0-9._-]{1,255}$ (URL-safe unreserved minus tilde)

Applied at three layers for defense-in-depth:

  1. Pydantic request model (CreateTargetRequest, TargetSummary)
  2. FastAPI path-parameter pattern= on the new routes
  3. DB-level CHECK constraints via additive migration a3e7b2f1c4d5

Race safety

  • ensure_target_by_natural_key uses INSERT ... ON CONFLICT DO NOTHING + re-select
  • upsert_target_control_attachment uses INSERT ... ON CONFLICT DO UPDATE SET enabled = EXCLUDED.enabled
  • Two concurrent PUTs for the same natural key both return 200 with the same target_id; no IntegrityError surfaces as 5xx
  • Dedicated concurrency tests in test_targets_natural_key.py::test_ensure_target_by_natural_key_race_safe and ::test_upsert_attachment_race_safe

What's intentionally unchanged

  • All existing target_id-based routes — kept as-is for backward compatibility
  • POST /targets — still strict (409 on duplicate; not made idempotent as part of this PR)
  • No natural-key GET /targets/{type}/{id} or GET /targets/{type}/{id}/controls routes; we deliberately scope this PR to the write path
  • No runtime lazy target creation — only management APIs create lazily
  • No JWT/auth redesign
  • No Galileo UI changes

Out-of-scope fix bundled

One small hygiene fix also included: evaluators/contrib/galileo/pyproject.toml was pinning mypy>=1.8.0 with no upper bound, which resolved to mypy==1.20.2 on recent lock refreshes. That version only ships cp314 wheels, breaking local dev for anyone on Python 3.12/3.13. Capped to <1.20. Unblocks the repo-wide pre-push hook.

Test plan

  • make check (models + server + engine + sdk + evaluators + lint + typecheck)
  • make sdk-ts-generate-check (after regeneration)
  • make sdk-ts-lint / make sdk-ts-typecheck / make sdk-ts-test
  • Server tests: 601 passed (19 new natural-key tests + existing regression surface)
  • Models tests: 95 passed (23 new charset-validation tests)
  • Coverage on touched files: endpoints/targets.py 95%, services/targets.py 99%, models/target.py 100%
  • Manual smoke-test of PUT and DELETE via local server

Reviewer focus points

  • server/src/agent_control_server/endpoints/targets.py — two new handlers at the bottom of the router
  • server/src/agent_control_server/services/targets.py — three new helpers (get_target_by_natural_key, ensure_target_by_natural_key, upsert_target_control_attachment)
  • server/alembic/versions/a3e7b2f1c4d5_targets_identifier_check_constraints.py — CHECK constraints on targets.target_type and targets.external_id
  • models/src/agent_control_models/target.py — constrained-string aliases
  • server/tests/test_targets_natural_key.py — covers lazy create, idempotent PUT, idempotent DELETE, tenant isolation (9a/b/c), control-not-in-tenant 404s, path-charset guards, and concurrency

Add two write-path endpoints so client-facing consumers can enable or
disable a control on a target using the natural identity
(target_type, external_id) without first looking up Agent Control's
internal integer target_id:

- PUT    /api/v1/targets/{target_type}/{external_id}/controls/{control_id}
- DELETE /api/v1/targets/{target_type}/{external_id}/controls/{control_id}

PUT is a desired-state endpoint. It lazily creates the target row with
empty metadata if absent, upserts the attachment to the requested
enabled flag (default true), and returns 200 with the current
attachment summary. DELETE is idempotent: 204 when the target, the
attachment, or both are absent in the caller's tenant. Both endpoints
preserve cross-tenant non-disclosure — a control that exists only in
another tenant returns the same 404 shape as a control that does not
exist anywhere.

Supporting changes:

- Path-safe identifier contracts. Introduce constrained-string aliases
  TargetTypeStr (^[a-z][a-z0-9_]{0,63}$) and ExternalIdStr
  (^[A-Za-z0-9._-]{1,255}$) in agent_control_models.target and apply
  them at the request model layer plus the new endpoints' path
  parameters. Adds an additive alembic migration
  (a3e7b2f1c4d5) with matching CHECK constraints at the DB layer so a
  bypass of the model layer cannot store path-breaking values.
- Race-safe service helpers. get_target_by_natural_key for raw lookup,
  ensure_target_by_natural_key using INSERT ... ON CONFLICT DO NOTHING
  + re-select, and upsert_target_control_attachment using
  ON CONFLICT DO UPDATE SET enabled = EXCLUDED.enabled.
- Observability. INFO log on lazy target creation carrying tenant_id,
  target_type, external_id, and the new target id.

Existing target_id-based routes are unchanged.
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 21, 2026

Codecov Report

❌ Patch coverage is 98.46154% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...erver/src/agent_control_server/services/targets.py 95.45% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@abhinav-galileo abhinav-galileo changed the title feat(server): natural-key attach/detach for target controls (PR7) feat(server): natural-key attach/detach for target controls (7/7) Apr 21, 2026
Adds the read-side complement to PR7's natural-key attach/detach:

  GET /api/v1/targets/{target_type}/{external_id}/controls

Returns 200 with the list of attachments for the target in the caller's
tenant, or 200 with an empty list when the target does not exist yet.
The empty-list shape matches desired-state semantics: clients rendering
a controls panel for a log_stream treat "target not yet created" the
same as "target exists but no controls attached". A 404 would add no
useful signal because a subsequent PUT creates the target lazily
anyway.

Introduces a new response model ListTargetControlsByNaturalKeyResponse
that echoes the caller's natural key back (target_type, external_id)
instead of exposing the internal int target_id. Callers already know
the natural key they used; they do not need the surrogate.

Path-parameter charset guards from PR7 apply; invalid target_type or
external_id values are rejected before the handler runs.

Regenerated TS SDK.
@abhinav-galileo abhinav-galileo changed the title feat(server): natural-key attach/detach for target controls (7/7) feat(server): natural-key endpoints for target controls (7/7) Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant