Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,20 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
no schema can be found the vectors report as `skip` with the flag to pass;
`--only schema` exits non-zero, so a skip cannot quietly pass for a pass.

- **Scoring edits are rate-limited per cell** (`amp_server.ratelimit`).
RFC-AMP-001 §5 lists decay-score manipulation as a threat and asks
implementations to rate-limit scoring PATCHes per cell; nothing did. A caller
looping on `scoring` can hold a cell `active` past its relevance window or drive
a competing memory into archive. The budget is per cell, counts only PATCHes that
actually carry `scoring` (refusing ordinary content edits to cover an attack they
have nothing to do with would be an outage, not a mitigation), and answers
`429 RATE_LIMITED` with `Retry-After`. A refused attempt is not recorded, so
hammering cannot push back the caller's own deadline. Access is checked first, so
a caller who may not write the cell learns nothing about the remaining budget.
Default 5 per cell per hour, changed with `AMP_SCORING_PATCH_LIMIT` /
`AMP_SCORING_PATCH_WINDOW_SECONDS`, disabled with `0`, and advertised at
`GET /spec` as `scoring_patch_limit` (`null` when off). Counters are per process.

### Changed
- **Every endpoint returns one error shape.** `PATCH /memories/{id}` answered a
conflict with `{"detail": ...}` while `DELETE` answered with
Expand Down
30 changes: 30 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ curl http://localhost:8765/amp/v1/spec
"mcp_compatible": false,
"storage_backends": ["chroma"],
"api_keys_required": false,
"scoring_patch_limit": {"max_patches": 5, "window_seconds": 3600},
"embedding": {"provider": "chroma-default", "dimensions": 384},
"max_cell_size_bytes": 65536,
"retention_days": 30,
Expand All @@ -158,6 +159,7 @@ suite checks it against the server's own numbers rather than a fixed value:
| Capability | What it commits the server to |
|---|---|
| `mcp_compatible` | Whether **this HTTP server** speaks MCP directly. It is `false`: the MCP integration ships as a separate stdio process (`amp-mcp`, see `examples/mcp-claude-desktop/`), not as an endpoint on this API. |
| `scoring_patch_limit` | How often one cell's `scoring` may be rewritten, and over what window (`null` when the limit is off). Enforced on `PATCH`: RFC-AMP-001 §5 names decay-score manipulation as a threat, because a caller looping on `scoring` can hold a cell `active` past its relevance window or push a competing memory into archive. A refusal is `429 RATE_LIMITED` with `Retry-After`. Section `PATCH /memories/{memory_id}` below covers what is and is not counted. |
| `max_page_size` | The largest `limit` the listing endpoints accept (`100`). A larger value is refused with `422` rather than silently clamped, so a client never believes it received a complete page when it did not. |
| `api_keys_required` | Whether this server requires `X-AMP-API-Key` (`AMP_API_KEYS_FILE` is set). Reported here so a client learns it needs a key before a call fails with `401`. |
| `storage_backends` | The adapter actually wired in (`chroma` or `postgres`), selected with `AMP_STORAGE_BACKEND`; see [getting started](getting-started.md#6-choosing-a-storage-backend). |
Expand Down Expand Up @@ -451,6 +453,34 @@ Fields that cannot be patched: `id`, `amp_version`, `identity`, `lifecycle.creat

---

### Scoring updates are rate-limited per cell

RFC-AMP-001 §5 lists decay-score manipulation as a threat: a caller PATCHing
`scoring` in a loop can keep a cell `active` past its intended relevance window,
or force a competing memory into archive. Two things bound it. A scoring change
only takes effect on the next lifecycle pass, so the engine's cadence limits how
fast a manipulation lands; and the server budgets how often one cell's `scoring`
may be rewritten at all.

- Only a PATCH that carries `scoring` is counted. Rewriting `content`, `provenance`
or `access_policy` is unaffected. `{"scoring": null}` changes nothing, so it
costs nothing.
- The budget is per cell, so one busy cell cannot use up another's.
- Refused edits are `429 RATE_LIMITED`, with `Retry-After` in seconds and the same
number in `error.details.retry_after_seconds`. The number is how long until the
oldest allowed edit leaves the window - a refused attempt is not recorded, so
retrying early does not push your own deadline back.
- Access is checked first: a caller who may not write the cell gets `403` and
learns nothing about the remaining budget.
- Default: 5 edits per cell per hour. `AMP_SCORING_PATCH_LIMIT` and
`AMP_SCORING_PATCH_WINDOW_SECONDS` change it; `AMP_SCORING_PATCH_LIMIT=0`
disables it, and [`GET /spec`](#get-spec) then reports `null`.

The counters live in the server process, so two processes over one storage backend
keep two budgets.

---

## DELETE /memories/{memory_id}

Soft-deletes a memory cell by setting `lifecycle.status` to `"deleted"`. The cell is retained in storage and will not appear in search results, but can still be retrieved directly by ID.
Expand Down
19 changes: 19 additions & 0 deletions server/amp_server/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,15 @@ def __init__(
code: str,
message: str,
details: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> None:
self.status_code = status_code
self.code = code
self.message = message
self.details = details or {}
# Part of the error for a caller who can act on it: a rate limit that
# does not say how long to wait leaves the client to guess.
self.headers = headers or {}
super().__init__(message)

def to_response(self) -> dict[str, Any]:
Expand Down Expand Up @@ -71,6 +75,21 @@ def unauthenticated() -> AMPError:
)


def rate_limited(retry_after_seconds: int) -> AMPError:
"""Too many scoring edits on one cell (RFC §5: decay-score manipulation).

`429` with `Retry-After`, because the caller can act on the number: it is
exactly how long until the oldest allowed edit ages out of the window.
"""
return AMPError(
429,
"RATE_LIMITED",
f"too many scoring updates for this cell; retry in {retry_after_seconds}s",
details={"retry_after_seconds": retry_after_seconds},
headers={"Retry-After": str(retry_after_seconds)},
)


def admin_disabled() -> AMPError:
"""Manual lifecycle runs are opt-in: no token configured means no access."""
return AMPError(
Expand Down
52 changes: 51 additions & 1 deletion server/amp_server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
admin_disabled,
invalid_transition,
missing_agent_id,
rate_limited,
unauthenticated,
)
from amp_server.lifecycle import LifecycleEngine
Expand All @@ -41,6 +42,11 @@
SearchResponse,
)
from amp_server.paging import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, readable_page
from amp_server.ratelimit import (
ScoringPatchLimiter,
limit_from_env,
patch_touches_scoring,
)
from amp_server.scheduler import lifecycle_loop, run_lifecycle, settings_from_env
from amp_server.storage.base import (
InvalidTransitionError,
Expand Down Expand Up @@ -75,6 +81,10 @@ def configure_logging() -> None:
# trusted as the spec's binding describes. Set from the lifespan, so a key store
# that cannot be read stops the server rather than turning into a 401 later.
_api_key_store: ApiKeyStore | None = None
# Resolved here, like the lifecycle settings, so a test can swap either without
# reaching into the environment. The limiter is the stateful half.
_scoring_limit = limit_from_env()
_scoring_limiter = ScoringPatchLimiter(_scoring_limit)


def _build_storage() -> StorageAdapter:
Expand Down Expand Up @@ -162,6 +172,7 @@ def get_lifecycle() -> LifecycleEngine:
@asynccontextmanager
async def lifespan(app: FastAPI):
global _storage, _lifecycle, _lifecycle_settings, _api_key_store
global _scoring_limit, _scoring_limiter
configure_logging()
# Built here, not at import: a misconfigured backend must stop the server
# from starting rather than surface as poor search results later.
Expand All @@ -171,6 +182,17 @@ async def lifespan(app: FastAPI):
# fall back to trusting an unverified header.
_api_key_store = store_from_env()

# Read again here for the same reason the lifecycle settings are: a test (or
# an embedder) can set AMP_SCORING_PATCH_LIMIT before starting the app.
_scoring_limit = limit_from_env()
_scoring_limiter = ScoringPatchLimiter(_scoring_limit)
if _scoring_limit.enabled:
logger.info(
"Scoring PATCH limit: %d per %ds per cell",
_scoring_limit.max_patches,
_scoring_limit.window_seconds,
)

# Read settings here, not only at import: a test (or an embedder) can set
# AMP_LIFECYCLE_* before starting the app and expect it to take effect.
_lifecycle_settings = settings_from_env()
Expand Down Expand Up @@ -227,7 +249,11 @@ async def _amp_error_handler(request: Request, exc: AMPError) -> JSONResponse:
`{"error": {"code", "message", "details"}}` body instead of each route
building its own, and so route handlers can stay annotated `-> dict`.
"""
return JSONResponse(status_code=exc.status_code, content=exc.to_response())
return JSONResponse(
status_code=exc.status_code,
content=exc.to_response(),
headers=exc.headers or None,
)


# The error envelope is part of the protocol, so it belongs in the contract
Expand All @@ -247,6 +273,13 @@ async def _amp_error_handler(request: Request, exc: AMPError) -> JSONResponse:
"(only when AMP_API_KEYS_FILE is configured)",
}
}
_RATE_LIMITED: dict[int | str, dict[str, Any]] = {
429: {
"model": ErrorResponse,
"description": "Too many scoring updates for this cell; see Retry-After "
"and GET /spec's scoring_patch_limit",
}
}
_ACCESS_DENIED: dict[int | str, dict[str, Any]] = {
403: {
"model": ErrorResponse,
Expand Down Expand Up @@ -288,6 +321,7 @@ async def spec() -> dict[str, Any]:
# before it makes a call that would fail with 401.
"api_keys_required": _api_key_store is not None,
"max_page_size": MAX_PAGE_SIZE,
"scoring_patch_limit": _scoring_limit.to_json(),
"embedding": get_storage().embedding,
"max_cell_size_bytes": MAX_CELL_SIZE_BYTES,
"retention_days": get_storage().retention_days,
Expand Down Expand Up @@ -452,6 +486,7 @@ async def get_memory(
| _ACCESS_DENIED
| _INVALID_TRANSITION
| _CELL_TOO_LARGE
| _RATE_LIMITED
),
)
async def update_memory(
Expand All @@ -475,6 +510,17 @@ async def update_memory(
if not check_write_access(cell, x_amp_agent_id):
raise access_denied()

if patch_touches_scoring(body):
# RFC §5: a caller looping on `scoring` can hold a cell active past its
# window or force a competitor into archive. Checked after the access
# rules, so a caller who may not write this cell is told that, and told
# nothing about how much budget is left. An allowed edit is recorded
# here, before the write, so a write that then fails does not buy the
# caller a free retry.
wait = _scoring_limiter.check_and_record(memory_id)
if wait:
raise rate_limited(wait)

try:
updated = await storage.update(memory_id, body)
except InvalidTransitionError as exc:
Expand Down Expand Up @@ -516,6 +562,10 @@ async def delete_memory(
except InvalidTransitionError as exc:
raise invalid_transition(exc.message) from exc

# The cell is gone from the API's point of view; its scoring budget has
# nothing left to protect, and holding the counters would keep an id alive
# in memory for a cell no caller can reach.
_scoring_limiter.forget(memory_id)
return Response(status_code=204)


Expand Down
Loading
Loading