diff --git a/docs/memory/architecture/api-endpoints.md b/docs/memory/architecture/api-endpoints.md index 9893534d6..bdbc0be73 100644 --- a/docs/memory/architecture/api-endpoints.md +++ b/docs/memory/architecture/api-endpoints.md @@ -274,7 +274,7 @@ WebSocket events: `operator_queue_new`, `operator_queue_responded` / `operator_q **Addressed asks (ent#364).** `operator_queue.addressed_to_email` names the HUMAN an item is for; `agent_name` only ever said which agent. NULL — every pre-ent#364 row — means "the operator answers", so the column is additive by construction. It is a **column and not a key in `context`** because it is an authorization decision (who may answer, whose Workspace sidebar it appears in) and `context` is agent-authored JSON whose clamp bounds size and type only. Validated at the one agent-authored boundary (`operator_queue_service._validated_addressee`, inside the #1632 clamp) against `client_portal.service.agent_on_roster(agent, email, include_owned=False)` — an agent may address only someone already on its roster — and **fails closed**: malformed, off-roster, or an unreadable roster all drop to NULL. The read/answer surface is **OSS core** (ent#428): `client_portal/asks/`, mounted unconditionally in `main.py` at `/api/enterprise/client-portal/asks` — a prefix that is retained history, like the `enterprise_`-prefixed portal tables, not a licensing claim. It re-checks the roster at read time so a revoked share stops showing an already-raised ask, projects an explicit client-facing shape (never `context`), and answers through the same ask sink as the operator respond route (`services/ask_service.py`, trinity-enterprise#611), so the ledger, write-back, audit and the WS trigger are unforked, and it is person-only like respond (a non-person platform principal gets 403 `person_required`). `responded_by_id` stays NULL for a client answerer — there is no `users` row, and inventing one would be a lie in the audit trail. **The list fails loud (trinity-enterprise#610 PR A0):** `GET /api/enterprise/client-portal/asks` returns **503 `{code: "asks_unavailable", message}`** with `Retry-After: 20` (the Workspace poll interval) when any of its queue reads raises (the agent set, the count or the page, #3059) or the read-time roster re-check cannot read the roster (`asks/service.AsksUnavailable`, strict `_on_roster`); it never answers an outage with `[]`. `answer_ask`'s roster failure stays the uniform 404. **One ownership check (trinity-enterprise#610 PR A2):** `asks/service._owned_ask` (lookup + addressee + `_VISIBLE_KINDS` + roster) is shared by the answer path (non-strict — its refusal is the 404 whatever the cause) and the context read (strict — a roster outage is 503); an addressed row of a kind no list shows is now a 404 on answer too. -**Ingestion caps (#1632).** There is **no HTTP create** — every operator-queue item is created through `db.create_operator_queue_item`, and the only untrusted producer is the sync service reading the agent-authored `~/.trinity/operator-queue.json` (`operator_queue_service._sync_agent`). #1402 makes this queue the approval channel for irreversible actions, so a compromised / prompt-injected agent that floods plausible "approve this" items causes operator fatigue → reflexive approval (XSS is already handled by DOMPurify; the exposure is volume + social engineering). The one agent-authored seam is bounded by two independent limits plus field hygiene, all env-tunable (see requirements §26.7): (1) a per-agent **pending-DEPTH cap** (`db.count_operator_queue_pending_for_agent`, default 25) — the **primary** bound, DB-measured ⇒ Redis-independent; at the cap the sync **stops** ingesting (never drips a growing file) and holds the surplus behind one aggregated alert; (2) a per-agent + fleet **create RATE limit** (`rate_limiter.check`, 60/60s + 300/60s, fail-open); (3) a total field-hygiene clamp (`_clamp_ingested_item`, inside the #1525 create try/except) — truncate-with-marker title/question, context/options over cap → marker (validated `execution_id` only), non-dict context → `{}`, `created_at` normalized to ingest time, priority validate-only; (4) a **reserved-id guard** rejecting agent ids that start with a platform-reserved prefix (`queue-flood-`/`poison-`/`cb-dormant-`/`sync-failing-`/`git-bloat-`/`skill-not-found-`/`val_`/`system-seed-`/`base-image-stale-`/`alert-budget-`) so an agent can't pre-create — and via `on_conflict_do_nothing` silence — its own flood alarm or the #1402 poison alert, plus a malformed/oversize-id reject; (5) a per-cycle scan bound (skip an oversized file wholesale; cap at 500 requests/cycle). The 5s sync loop is **leader-locked** (`opqueue:leader`, mirror monitoring #1464) so `--workers 2` doesn't double-charge / double-broadcast / double-scan. Platform creates bypass `_sync_agent`, and the exemption is **scoped by influence, not by caller location (#1677)**: a *platform-only* emitter (volume bound by platform cadence — edge-triggered, idempotent/bucketed id, leader-locked, or operator-driven: lease-reaper poison-park #1402, `validation_service._notify_operator_on_failure` — converted to a **direct DB create** in #1632 — and the internal breaker/skill/git alerts) stays direct and unthrottled, while an *agent-influenceable* one — `task_execution_service._alert_skill_not_found` (#1410), whose per-command dedup distinct unknown slash-commands defeat at $0/turn — routes through `operator_queue_service.create_bounded_alert`: a per-(agent, registered-type) pending-DEPTH budget (`OPERATOR_ALERT_MAX_PENDING_PER_TYPE`, default 5; type derived from `item["type"]` against the `_BUDGETED_ALERT_TYPES` frozenset; **fail-closed on every arm** — an unreadable count / unregistered type / failed create suppresses the ALERT only, the FAILED execution rows stay the primary surface, and the paired notification is gated on the same bool), with ONE cooldown-gated `alert-budget-{agent}-{type}-b{bucket}` episode alert per window (deterministic bucket ⇒ the `(agent_name, request_id)` on-conflict target dedups cross-worker; no `held` count, no agent-controlled text). The classification is CI-forced by the AST caller-parity guard `tests/unit/test_1677_operator_alert_emitters.py` (every `create_operator_queue_item` call site must be allowlisted platform-only or routed; OSS tree only — the private submodule owns its twin); a sink-level default bound was rejected because it fails QUIETLY at the load-bearing poison-park create where the parity test fails LOUDLY at CI (#1890's lesson deliberately inverted). A generous DB-sink belt in `create_item` (reject title>4 KiB / question>16 KiB / context>64 KiB / id>512; the derived `execution_id` COLUMN belted to None on non-str/>512, #1677) is a second layer so the exemption is never solely load-bearing (#1525 validate-at-boundary-AND-at-sink). A depth-held / rate-skipped / oversize-file episode emits **one** `queue-flood-{agent}-{utc_now_iso()}` item per over-cap **episode** (#3130: edge-triggered — a second alert needs the condition to have cleared; the 300 s cooldown is only the minimum spacing between episodes), created through `create_bounded_alert` as the budgeted type `queue_flood` (#1677 backstop: ≤5 pending per agent), un-guessable id, priority `high`, wrapped so an emit failure never kills the sync. Platform-minted rows do **not** count toward the agent's own depth (file seam and native `queue_full` read one predicate, `_own_pending_conds`, with `_RESERVED_ID_PREFIXES` excluded) — before #3130 the alert counted toward the depth it reported and was re-minted every cooldown window (386 of one agent's 435 rows). A held file is told so: a file-level `platform.ingestion` block (`reason` ∈ `queue_full`/`rate_limited`/`invalid_id`/`invalid_options`/`invalid_title`, `max_pending`, `since`; since #3243 an entry over the authoring caps is held with its id listed — ≤10 — under `invalid_options` / `invalid_title` beside `max_options` / `max_option_chars` / `max_title_chars`, whatever `reason` wins) rides the guarded write-back, written only when it changes and removed once nothing is held; a refused write is retried only after the file changes. The pre-#3130 backlog is collapsed once by the `supersede_queue_flood_backlog` migration (both tracks): newest pending flood row per agent kept, the rest `cancelled`, `disposed_by = 'platform'`, reason `superseded`. The platform-emitter residual named on the pull-mode default-ON gate list (#1081 / `TARGET_ARCHITECTURE.md`) is closed by #1677; the remaining gate items are unchanged. +**Ingestion caps (#1632).** There is **no HTTP create** — every operator-queue item is created through `db.create_operator_queue_item`, and the only untrusted producer is the sync service reading the agent-authored `~/.trinity/operator-queue.json` (`operator_queue_service._sync_agent`). #1402 makes this queue the approval channel for irreversible actions, so a compromised / prompt-injected agent that floods plausible "approve this" items causes operator fatigue → reflexive approval (XSS is already handled by DOMPurify; the exposure is volume + social engineering). The one agent-authored seam is bounded by two independent limits plus field hygiene, all env-tunable (see requirements §26.7): (1) a per-agent **pending-DEPTH cap** (`db.count_operator_queue_pending_for_agent`, default 25) — the **primary** bound, DB-measured ⇒ Redis-independent; at the cap the sync **stops** ingesting (never drips a growing file) and holds the surplus behind one aggregated alert; (2) a per-agent + fleet **create RATE limit** (`rate_limiter.check`, 60/60s + 300/60s, fail-open); (3) a total field-hygiene clamp (`_clamp_ingested_item`, inside the #1525 create try/except) — truncate-with-marker title/question/type (`type`→64 chars, #3385), context/options over cap → marker (validated `execution_id` only), non-dict context → `{}`, `created_at` normalized to ingest time, priority validate-only; (4) a **reserved-id guard** rejecting agent ids that start with a platform-reserved prefix (`queue-flood-`/`poison-`/`cb-dormant-`/`sync-failing-`/`git-bloat-`/`skill-not-found-`/`val_`/`system-seed-`/`base-image-stale-`/`alert-budget-`) so an agent can't pre-create — and via `on_conflict_do_nothing` silence — its own flood alarm or the #1402 poison alert, plus a malformed/oversize-id reject; (5) a per-cycle scan bound (skip an oversized file wholesale; cap at 500 requests/cycle). The 5s sync loop is **leader-locked** (`opqueue:leader`, mirror monitoring #1464) so `--workers 2` doesn't double-charge / double-broadcast / double-scan. Platform creates bypass `_sync_agent`, and the exemption is **scoped by influence, not by caller location (#1677)**: a *platform-only* emitter (volume bound by platform cadence — edge-triggered, idempotent/bucketed id, leader-locked, or operator-driven: lease-reaper poison-park #1402, `validation_service._notify_operator_on_failure` — converted to a **direct DB create** in #1632 — and the internal breaker/skill/git alerts) stays direct and unthrottled, while an *agent-influenceable* one — `task_execution_service._alert_skill_not_found` (#1410), whose per-command dedup distinct unknown slash-commands defeat at $0/turn — routes through `operator_queue_service.create_bounded_alert`: a per-(agent, registered-type) pending-DEPTH budget (`OPERATOR_ALERT_MAX_PENDING_PER_TYPE`, default 5; type derived from `item["type"]` against the `_BUDGETED_ALERT_TYPES` frozenset; **fail-closed on every arm** — an unreadable count / unregistered type / failed create suppresses the ALERT only, the FAILED execution rows stay the primary surface, and the paired notification is gated on the same bool), with ONE cooldown-gated `alert-budget-{agent}-{type}-b{bucket}` episode alert per window (deterministic bucket ⇒ the `(agent_name, request_id)` on-conflict target dedups cross-worker; no `held` count, no agent-controlled text). The classification is CI-forced by the AST caller-parity guard `tests/unit/test_1677_operator_alert_emitters.py` (every `create_operator_queue_item` call site must be allowlisted platform-only or routed; OSS tree only — the private submodule owns its twin); a sink-level default bound was rejected because it fails QUIETLY at the load-bearing poison-park create where the parity test fails LOUDLY at CI (#1890's lesson deliberately inverted). A generous DB-sink belt in `create_item` (reject title>4 KiB / question>16 KiB / type>1 KiB / context>64 KiB / id>512; the derived `execution_id` COLUMN belted to None on non-str/>512, #1677) is a second layer so the exemption is never solely load-bearing (#1525 validate-at-boundary-AND-at-sink). A depth-held / rate-skipped / oversize-file episode emits **one** `queue-flood-{agent}-{utc_now_iso()}` item per over-cap **episode** (#3130: edge-triggered — a second alert needs the condition to have cleared; the 300 s cooldown is only the minimum spacing between episodes), created through `create_bounded_alert` as the budgeted type `queue_flood` (#1677 backstop: ≤5 pending per agent), un-guessable id, priority `high`, wrapped so an emit failure never kills the sync. Platform-minted rows do **not** count toward the agent's own depth (file seam and native `queue_full` read one predicate, `_own_pending_conds`, with `_RESERVED_ID_PREFIXES` excluded) — before #3130 the alert counted toward the depth it reported and was re-minted every cooldown window (386 of one agent's 435 rows). A held file is told so: a file-level `platform.ingestion` block (`reason` ∈ `queue_full`/`rate_limited`/`invalid_id`/`invalid_options`/`invalid_title`, `max_pending`, `since`; since #3243 an entry over the authoring caps is held with its id listed — ≤10 — under `invalid_options` / `invalid_title` beside `max_options` / `max_option_chars` / `max_title_chars`, whatever `reason` wins) rides the guarded write-back, written only when it changes and removed once nothing is held; a refused write is retried only after the file changes. The pre-#3130 backlog is collapsed once by the `supersede_queue_flood_backlog` migration (both tracks): newest pending flood row per agent kept, the rest `cancelled`, `disposed_by = 'platform'`, reason `superseded`. The platform-emitter residual named on the pull-mode default-ON gate list (#1081 / `TARGET_ARCHITECTURE.md`) is closed by #1677; the remaining gate items are unchanged. ### Skill Gate — the in-container check (trinity-enterprise#752) diff --git a/docs/memory/feature-flows/operating-room.md b/docs/memory/feature-flows/operating-room.md index 2eef5927f..a6ffdb20f 100644 --- a/docs/memory/feature-flows/operating-room.md +++ b/docs/memory/feature-flows/operating-room.md @@ -582,13 +582,13 @@ Two independent bounds plus field hygiene, applied in-order per new `pending` re 1. **DEPTH cap (primary, DB-measured ⇒ Redis-independent).** `pending = db.count_operator_queue_pending_for_agent(agent)` is computed once per cycle; a new item is admitted only while `pending + admitted < OPERATOR_QUEUE_MAX_PENDING_PER_AGENT` (25). At the cap the scan **stops** (`break`) — it does not keep dripping a growing file (that would re-scan on every 5s cycle → per-cycle DoS). Because it is DB-measured it holds through a Redis outage, which is why fail-open on the rate limiters is safe. Depth is the fatigue metric: a rate-aware drip that never trips the rate limit still can't accumulate more than 25 pending items. 2. **RATE cap (burst smoothing, Redis, fail-open).** Per-agent `rate_limiter.check("operator_queue_create:{agent}", 60, 60)` + fleet `check("operator_queue_create:_fleet", 300, 60)`, charged only at the real create point. Denied → the item is **held** this cycle and the scan `break`s (the window is monotonic within a cycle). The fleet cap bounds a colluding / shared-upstream-injected fleet in aggregate. -3. **Field hygiene** (`_clamp_ingested_item`, a total helper run **inside** the #1525 create try/except so any failure is quarantined, never hot-looped): `title`→300 / `question`→4000 chars **truncate-with-marker** (losing a real approval is worse than a clamped one; since #3243 a new agent entry with a title over 120 — `OPERATOR_QUEUE_ASK_TITLE_MAX_CHARS` — is held as `invalid_title` before this clamp, so the 300 truncation is reached only with that knob raised above 300); `context` serialized >8 KiB or non-serializable → `{"_truncated":true,"_original_bytes":N,"execution_id":}` (so a huge `execution_id` can't smuggle past the context cap); non-dict `context` → `{}`; `options` >4 KiB → a small dropped-marker list; `priority` validate-only (unknown → `medium`, legit `critical` untouched — the depth cap already bounds critical *volume*); **`created_at` normalized to ingest time** (an agent-supplied future date would pin the item atop the `created_at DESC` sort). `expires_at` is honored as authored. +3. **Field hygiene** (`_clamp_ingested_item`, a total helper run **inside** the #1525 create try/except so any failure is quarantined, never hot-looped): `title`→300 / `question`→4000 / `type`→64 (#3385) chars **truncate-with-marker** (losing a real approval is worse than a clamped one; since #3243 a new agent entry with a title over 120 — `OPERATOR_QUEUE_ASK_TITLE_MAX_CHARS` — is held as `invalid_title` before this clamp, so the 300 truncation is reached only with that knob raised above 300); `context` serialized >8 KiB or non-serializable → `{"_truncated":true,"_original_bytes":N,"execution_id":}` (so a huge `execution_id` can't smuggle past the context cap); non-dict `context` → `{}`; `options` >4 KiB → a small dropped-marker list; `priority` validate-only (unknown → `medium`, legit `critical` untouched — the depth cap already bounds critical *volume*); **`created_at` normalized to ingest time** (an agent-supplied future date would pin the item atop the `created_at DESC` sort). `expires_at` is honored as authored. 4. **Reserved-id guard + malformed-id reject.** An agent item whose `id` starts with a platform-reserved prefix (`queue-flood-`, `poison-`, `cb-dormant-`, `sync-failing-`, `git-bloat-`, `skill-not-found-`, `val_`, `system-seed-`, `base-image-stale-`, `alert-budget-` (#1677), `sub-headroom-` (ent#434), `skills-legacy-adoption-` (#2744), `portal-inbox-collision-` (trinity-enterprise#715)) is **rejected** so an agent can't pre-create — and via `create_item`'s `on_conflict_do_nothing` silence — its own flood alarm or the #1402 poison alert. An `id` >256 chars or not matching `^[A-Za-z0-9._:-]+$` is rejected (a create PK can't be safely rewritten). 5. **Flood alert.** A depth-held / rate-skipped / malformed-id / oversize-file episode emits **one** `queue-flood-{agent}-{utc_now_iso()}` item per over-cap **episode** (#3130: edge-triggered — a second alert needs the condition to have cleared; the 300 s cooldown is only the minimum spacing between episodes), created through `create_bounded_alert` as the budgeted type `queue_flood` (#1677 backstop: ≤5 pending per agent), un-guessable id, priority `high`, wrapped so an emit failure never kills the sync. Platform-minted rows do **not** count toward the agent's own depth (file seam and native `queue_full` read one predicate, `_own_pending_conds`, with `_RESERVED_ID_PREFIXES` excluded) — before #3130 the alert counted toward the depth it reported and was re-minted every cooldown window (386 of one agent's 435 rows). A held file is told so: a file-level `platform.ingestion` block (`reason` ∈ `queue_full`/`rate_limited`/`invalid_id`/`invalid_options`/`invalid_title`, `max_pending`, `since`; since #3243 an entry over the authoring caps is held with its id listed — ≤10 — under `invalid_options` / `invalid_title` beside `max_options` / `max_option_chars` / `max_title_chars`, whatever `reason` wins) rides the guarded write-back, written only when it changes and removed once nothing is held; a refused write is retried only after the file changes. The pre-#3130 backlog is collapsed once by the `supersede_queue_flood_backlog` migration (both tracks): newest pending flood row per agent kept, the rest `cancelled`, `disposed_by = 'platform'`, reason `superseded`. The single leader ⇒ no WS double-broadcast. **Deferred/held semantics.** Held items are **not dropped** — they stay `pending` in the agent's file and are re-evaluated on a later cycle once depth/rate frees up (or after the operator resolves existing items). This is a behavior change agents must understand: an agent-supplied `created_at` is ignored, and a burst beyond the caps is throttled rather than fully ingested at once. -**Generous DB-sink belt.** `create_item` independently rejects (`ValueError`) `title`>4 KiB / `question`>16 KiB / serialized `context`>64 KiB / `id`>512 — an order of magnitude above the service caps so a clamped agent item or a small platform item never trips it, but the "platform bypasses the boundary" exemption stops being *solely* load-bearing (#1525's validate-at-boundary-AND-at-sink). +**Generous DB-sink belt.** `create_item` independently rejects (`ValueError`) `title`>4 KiB / `question`>16 KiB / `type`>1 KiB / serialized `context`>64 KiB / `id`>512 — an order of magnitude above the service caps so a clamped agent item or a small platform item never trips it, but the "platform bypasses the boundary" exemption stops being *solely* load-bearing (#1525's validate-at-boundary-AND-at-sink). **Platform exemption & emitter budget (#1677).** Platform callers create straight through `db.create_operator_queue_item`, bypassing `_sync_agent` and therefore every file-seam cap — but the exemption is **split by influence, not by caller location**. A *platform-only* emitter — volume bound by platform cadence (edge-triggered, idempotent/bucketed id, leader-locked, or operator-driven): lease-reaper poison-park #1402, `agent_client` circuit-breaker-dormant, `sync_health_service` git-sync/bloat, the skills / system-seed / base-image / retention-guard / portal-inbox alarms (of which `skill_service._record_adoption_failure` carries its bound **per branch** since #2744: the terminal *"this install already has sources"* refusal is `low` with an idempotent URL-keyed id (`skills-legacy-adoption-refused-{sha256(url)[:12]}`, family prefix reserved) ⇒ **≤1 row per refused URL** with an operator's dismissal sticking, while its two genuine failure branches stay `high` and timestamped by product decision. That bound is **not** the "operator-driven" disjunct of the list above — its `_ALLOWED_CALLERS` entry claimed "admin-driven sync cadence" until #2744, and that was false: ent#236's auto-sync drives the same `sync_library()` unattended on a 300s-86400s timer, so what makes it platform-only is that its ONLY input is `skills_library_url`, which `routers/settings.py` blocks on the generic settings PUT (`LEGACY_SKILLS_LIBRARY_KEYS`, 422) and no other writer reaches), `subscription_headroom_alerts._emit` (ent#434 — edge-triggered per weekly window via a deterministic reset-day id whose on-conflict makes a re-emit a no-op, plus a per-cycle cap; no agent input reaches any field), and `validation_service._notify_operator_on_failure` (**converted to a direct DB create in #1632**, which also fixed a latent bug where it appended to a bare list the sync loop can't parse) — stays direct and unthrottled. An *agent-influenceable* emitter — one whose VOLUME an agent can drive: `task_execution_service._alert_skill_not_found` (#1410: one `priority:"high"` item + notification per **distinct** unknown slash-command, at $0/turn — the per-command dedup and timestamped id never collapse distinct commands) and `git_service._emit_gitignore_untracked_alert` (#2529: the per-Push `.gitignore` sweep names what it untracked **and what it newly un-ignored** — the gate is `GitignoreSweep.changed_tracking`, both directions, because the rebuild that stops a managed default reversing an agent negation can also newly un-ignore a path the same Push then commits; and `sync_to_github` is reachable from the `git_sync` MCP tool, which an agent-scoped key may call on itself — a repeated `git add -f ` + sync loop yields a fresh removed set every time against a timestamped, non-idempotent id, so nothing upstream bounds it; note its `git_bloat`/`sync_failing` SIBLINGS stay direct, because those fire on the 60-second poller's cadence, which is the whole influence-not-location distinction) — routes through `operator_queue_service.create_bounded_alert`: a per-(agent, registered-type) pending-DEPTH budget (`OPERATOR_ALERT_MAX_PENDING_PER_TYPE`, default 5; DB-measured ⇒ Redis-independent, the #1632 primary-bound mirror; deliberately no rate cap — depth is rate-independent, a faster spray only reaches the cap sooner). The budget type is derived from `item["type"]` against the `_BUDGETED_ALERT_TYPES` frozenset registry (registering a type is a one-line reviewed act) and **every arm is fail-closed**: an unregistered type, an unreadable count, or a failed create suppresses the ALERT only — the FAILED/`SKILL_NOT_FOUND` execution rows stay the primary observability surface, and the caller gates the paired notification on the same bool so it can never outlive its queue item. Ordering in `_alert_skill_not_found` is pinned **dedup → budget → both creates** (a benign repeat of an already-pending command at cap stays a silent no-op, never a false "compromised" signal), and the agent-derived command is truncated (~200 chars, marker) on every echo surface. At the cap, ONE cooldown-gated (`OPERATOR_QUEUE_FLOOD_ALERT_COOLDOWN_SECONDS`) episode alert per window with the deterministic reserved id `alert-budget-{agent}-{type}-b{bucket}` — the bucket makes the `(agent_name, request_id)` on-conflict target a cross-worker dedup backstop; the alert carries no `held` count and no agent-controlled text (context = `reason`/`alert_type`/`cap`[/`last_triggered_by` platform enum]). The classification is **CI-forced**: the AST caller-parity guard `tests/unit/test_1677_operator_alert_emitters.py` fails any unclassified `create_operator_queue_item` call site (both facade spellings), and its `_ALLOWED_CALLERS` dict is the **living caller inventory** — (path, qualname) + a one-line justification per entry (OSS tree only; the enterprise submodule owns its own twin). A sink-level default bound inside `create_item` was rejected on fail-direction grounds: it fails QUIETLY at the load-bearing poison-park create (a park happens only on a successful create), where the parity test fails LOUDLY at CI (#1890's lesson deliberately inverted). diff --git a/docs/memory/requirements/security.md b/docs/memory/requirements/security.md index b0c6e0679..11f14d6f2 100644 --- a/docs/memory/requirements/security.md +++ b/docs/memory/requirements/security.md @@ -550,12 +550,12 @@ - **Description**: The operator-queue **create path** (the agent-authored `~/.trinity/operator-queue.json` sync ingestion boundary in `_sync_agent`) previously accepted unbounded agent input with no per-agent ingestion cap. #1402 makes this queue the **approval channel for irreversible actions** (payments, destructive ops), so a compromised / prompt-injected agent that floods plausible "approve this" items causes **operator fatigue → reflexive approval**. XSS is already handled (DOMPurify); the exposure is **volume + social engineering**. The fix must bound a **hostile** agent, not just a runaway. Two independent bounds plus field hygiene, all at the one agent-authored seam: - **Depth cap (primary, DB-measured ⇒ Redis-independent)**: `db.count_operator_queue_pending_for_agent(agent)` is computed once per cycle; new items are admitted only while `pending + admitted < OPERATOR_QUEUE_MAX_PENDING_PER_AGENT` (default **25**). At the cap, ingestion **stops** (`break`, not drip — avoids the C1 per-cycle DoS of re-scanning a growing file) and the surplus is held behind **one aggregated summary alert**. Bounds per-agent pending rows to `MAX_PENDING (+ platform items)` regardless of Redis. - **Rate cap (burst smoothing, Redis, fail-open)**: per-agent `rate_limiter.check("operator_queue_create:{agent}", OPERATOR_QUEUE_CREATE_RATE_LIMIT=60, OPERATOR_QUEUE_CREATE_RATE_WINDOW=60)` + fleet-level `check("operator_queue_create:_fleet", OPERATOR_QUEUE_FLEET_CREATE_RATE_LIMIT=300, 60)` at the real create point only. Denied → item held this cycle, `break` the new-item scan. The fleet cap bounds a colluding / shared-upstream-injected fleet in aggregate (#1402 threat model, #1085 governor precedent). - - **Field hygiene (`_clamp_ingested_item`, total helper, run INSIDE the #1525 create try/except)**: `title` truncated to `OPERATOR_QUEUE_TITLE_MAX` (300), `question` to `OPERATOR_QUEUE_QUESTION_MAX` (4000) — **truncate-with-marker** (losing a real approval is worse than a clamped one; since #3243 a new agent entry with a title over `OPERATOR_QUEUE_ASK_TITLE_MAX_CHARS` (120) is held before this clamp — see the authoring-caps bullet — so the 300 truncation is reached only with that knob raised above 300); `context` serialized >`OPERATOR_QUEUE_CONTEXT_MAX_BYTES` (8192) → replaced by a `{"_truncated":true,"_original_bytes":N,"execution_id":}` marker (so the context cap can't be defeated by a verbatim `execution_id`); non-dict `context` → `{}` (fixes the pre-existing `create_item` `.get` crash class); `options` serialized >`OPERATOR_QUEUE_OPTIONS_MAX_BYTES` (4096) → dropped-with-marker; agent-supplied `created_at` **normalized to ingest time** (defeats future-date sort-pinning; `expires_at` still honored); `priority` validate-only (unknown → `medium`; legit `critical` untouched — the depth cap already bounds critical *volume*). + - **Field hygiene (`_clamp_ingested_item`, total helper, run INSIDE the #1525 create try/except)**: `title` truncated to `OPERATOR_QUEUE_TITLE_MAX` (300), `question` to `OPERATOR_QUEUE_QUESTION_MAX` (4000), `type` to `OPERATOR_QUEUE_TYPE_MAX` (64, a plain constant, #3385) — **truncate-with-marker** (losing a real approval is worse than a clamped one; since #3243 a new agent entry with a title over `OPERATOR_QUEUE_ASK_TITLE_MAX_CHARS` (120) is held before this clamp — see the authoring-caps bullet — so the 300 truncation is reached only with that knob raised above 300); `context` serialized >`OPERATOR_QUEUE_CONTEXT_MAX_BYTES` (8192) → replaced by a `{"_truncated":true,"_original_bytes":N,"execution_id":}` marker (so the context cap can't be defeated by a verbatim `execution_id`); non-dict `context` → `{}` (fixes the pre-existing `create_item` `.get` crash class); `options` serialized >`OPERATOR_QUEUE_OPTIONS_MAX_BYTES` (4096) → dropped-with-marker; agent-supplied `created_at` **normalized to ingest time** (defeats future-date sort-pinning; `expires_at` still honored); `priority` validate-only (unknown → `medium`; legit `critical` untouched — the depth cap already bounds critical *volume*). - **Reserved-id guard + malformed-id reject**: an agent item whose `id` starts with a platform-reserved prefix (`queue-flood-`, `poison-`, `cb-dormant-`, `sync-failing-`, `git-bloat-`, `skill-not-found-`, `val_`, `system-seed-`, `base-image-stale-`, `alert-budget-` (#1677), `skills-legacy-adoption-` (#2744)) is **rejected** so an agent can't pre-create (and thereby self-suppress via `on_conflict_do_nothing`) its own flood alarm or the #1402 poison alert; an `id` longer than `OPERATOR_QUEUE_ID_MAX` (256) or not matching `^[A-Za-z0-9._:-]+$` is rejected (a PK can't be safely rewritten). - **Leader lock** (`opqueue:leader`, mirror monitoring #1464): only the lease-holding uvicorn worker runs `_poll_cycle`, so `--workers 2` no longer double-charges the limiter, double-broadcasts the alert, or double-scans the file. Fail-open to leader on Redis down. - **Summary/flood alert**: when depth-held or rate-skipped items occur, emits **one** `queue-flood-{agent}-{utc_now_iso()}` item per over-cap **episode** (#3130: edge-triggered — a second alert needs the condition to have cleared; the 300 s cooldown is only the minimum spacing between episodes), created through `create_bounded_alert` as the budgeted type `queue_flood` (#1677 backstop: ≤5 pending per agent), un-guessable id, priority `high`, wrapped so an emit failure never kills the sync. Platform-minted rows do **not** count toward the agent's own depth (file seam and native `queue_full` read one predicate, `_own_pending_conds`, with `_RESERVED_ID_PREFIXES` excluded) — before #3130 the alert counted toward the depth it reported and was re-minted every cooldown window (386 of one agent's 435 rows). A held file is told so: a file-level `platform.ingestion` block (`reason` ∈ `queue_full`/`rate_limited`/`invalid_id`/`invalid_options`/`invalid_title`, `max_pending`, `since`; since #3243 an entry over the authoring caps is held with its id listed — ≤10 — under `invalid_options` / `invalid_title` beside `max_options` / `max_option_chars` / `max_title_chars`, whatever `reason` wins) rides the guarded write-back, written only when it changes and removed once nothing is held; a refused write is retried only after the file changes. The pre-#3130 backlog is collapsed once by the `supersede_queue_flood_backlog` migration (both tracks): newest pending flood row per agent kept, the rest `cancelled`, `disposed_by = 'platform'`, reason `superseded`. - **Authoring caps (#3243)**: before the depth and rate caps (so a held entry spends no token and is not counted in `held` — no flood alert), a NEW agent entry with more than `OPERATOR_QUEUE_MAX_OPTIONS` (5) options, an option over `OPERATOR_QUEUE_OPTION_MAX_CHARS` (60) characters, an option that reads as the `(something else)` chip (case, whitespace and parentheses ignored; NFKC-folded and zero-width-stripped; cross-script confusables out of scope) or a title over `OPERATOR_QUEUE_ASK_TITLE_MAX_CHARS` (120) is **held, never truncated**: no row, and its id is listed (≤10) on the `platform.ingestion` marker under `invalid_options` / `invalid_title` with the limits in force; the id leaves the list once the entry fits. The native raise refuses the same shapes with `too_many_options` / `option_too_long` / `title_too_long` / `invalid_options` (§26.10). Rows already in the queue are never re-judged. - - **Generous DB belt** (`create_item`): rejects (`ValueError`) `title`>4 KiB, `question`>16 KiB, serialized `context`>64 KiB, `id`>512 — an order of magnitude above the service caps so platform items never trip it, but the "platform bypasses the boundary" invariant stops being solely load-bearing (#1525 two-layer philosophy: validate at the boundary AND at the sink). + - **Generous DB belt** (`create_item`): rejects (`ValueError`) `title`>4 KiB, `question`>16 KiB, `type`>1 KiB, serialized `context`>64 KiB, `id`>512 — an order of magnitude above the service caps so platform items never trip it, but the "platform bypasses the boundary" invariant stops being solely load-bearing (#1525 two-layer philosophy: validate at the boundary AND at the sink). - **Platform exemption made true**: `validation_service._notify_operator_on_failure` now creates its notification via a **direct** `db.create_operator_queue_item(...)` instead of writing into the agent file (which would flow through `_sync_agent` and be capped) — this restores exemption-by-construction and fixes the pre-existing latent bug where it `.append`ed to a bare list the sync loop can't parse. **The exemption is scoped by influence, not by caller location (#1677)**: exempt = *platform-only* (volume bound by platform cadence); an *agent-influenceable* direct emitter is **budgeted** — see the next bullet. - **Platform-emitter budget (#1677)**: platform direct-DB creates bypass the file-seam caps by construction, and one of them — `task_execution_service._alert_skill_not_found` (#1410) — was agent-influenceable: per-COMMAND dedup + a timestamped id meant each **distinct** unknown slash-command minted a new `priority:"high"` pending row + a notification, uncapped. The unresolved command is a **$0 turn**, so via `agent`/`a2a`/`schedule`/`reminder` triggers the spray runs at dispatch throughput (the loop PoC needs explicit `on_failure="continue", max_consecutive_failures=N` — the default aborts at iteration 1). Fix: such emitters route through `operator_queue_service.create_bounded_alert` — a per-(agent, registered-type) pending-DEPTH cap (`OPERATOR_ALERT_MAX_PENDING_PER_TYPE`, default **5**; DB-measured ⇒ Redis-independent, the #1632 primary-bound mirror; deliberately **no rate cap** — depth is rate-independent, a fast spray only reaches the cap sooner). The budget type is derived from `item["type"]` against the `_BUDGETED_ALERT_TYPES` frozenset registry (today `{skill_not_found}`; registration is a one-line reviewed act) and **every arm fails closed**: an unregistered type, an unreadable count, or a failed create suppresses the ALERT only — the FAILED/`SKILL_NOT_FOUND` execution rows stay the primary observability surface, and the caller gates the paired `db.create_notification` on the same bool so a notification never outlives its queue item. Ordering pinned **dedup → budget → both creates**; the agent-derived command is truncated (~200 chars, marker) on every echo surface (a >16 KiB command used to trip the DB question belt and self-suppress the whole alert). At the cap: ONE cooldown-gated episode alert per `OPERATOR_QUEUE_FLOOD_ALERT_COOLDOWN_SECONDS` window per (agent, type), deterministic reserved id `alert-budget-{agent}-{type}-b{bucket}` (the `(agent_name, request_id)` on-conflict target dedups cross-worker; prefix in `_RESERVED_ID_PREFIXES` so an agent can't pre-suppress it), **no `held` count** (untruthful at a first-trip emit) and **no agent-controlled text** (context = `reason`/`alert_type`/`cap`[/`last_triggered_by` platform enum — a triage aid for the cross-agent budget-fill threat: a permitted peer spraying at a victim fills the victim's budget; blast radius one agent × one type, junk rows stay visible, per-row `triggered_by` context identifies the source]). Classification is **CI-forced**: the AST caller-parity guard `tests/unit/test_1677_operator_alert_emitters.py` fails any unclassified `create_operator_queue_item` call site (both facade spellings; `_ALLOWED_CALLERS` keyed (path, qualname) with per-entry justification is the living caller inventory). Scope: the **OSS tree only** — the enterprise submodule is unmounted on public CI and differs per clone, so the private repo owns its own twin (stated residual). A sink-level default bound in `create_item` was rejected on fail-direction grounds: it fails QUIETLY at the load-bearing #1402 poison-park create (park only on success), where the parity test fails LOUDLY at CI (#1890's lesson deliberately inverted). `count_pending_for_agent` gained an optional `item_type` (query-only — no schema change, no migration on either track); `create_item` additionally belts the derived `execution_id` COLUMN (non-str or >512 → None, never truncated — a truncated id feigns validity; `""` unchanged). Closes the platform-emitter residual named on the #1081 pull-mode default-ON gate list. - **Fail-open policy**: the rate/fleet limiters fail open to the per-worker in-process window; the **DB depth cap** is the Redis-independent hard bound, so fail-open never leaves the channel unbounded (a Redis outage is covered by the depth cap, not by a fail-closed defer that would delay legit escalations). The #1677 budget inverts this for the *alert* surface: its count read fails **closed** (alert suppressed, executions list unaffected) because count degradation is attacker-influenceable and the alert is a secondary surface. diff --git a/src/backend/db/operator_queue.py b/src/backend/db/operator_queue.py index 2eb97965c..294296146 100644 --- a/src/backend/db/operator_queue.py +++ b/src/backend/db/operator_queue.py @@ -60,11 +60,12 @@ def _note_commit_lag(stamp: str) -> None: # "platform bypasses the boundary" exemption stops being solely load-bearing — # a caller that skips the service clamp still can't persist a multi-MB field. # An order of magnitude above the service caps (title 300 / question 4000 / -# context 8 KiB / id 256). +# context 8 KiB / id 256 / type 64). _DB_BELT_TITLE_MAX_BYTES = 4 * 1024 _DB_BELT_QUESTION_MAX_BYTES = 16 * 1024 _DB_BELT_CONTEXT_MAX_BYTES = 64 * 1024 _DB_BELT_ID_MAX = 512 +_DB_BELT_TYPE_MAX_BYTES = 1024 # trinity-enterprise#751: gate-raised rows (gated-skill approvals and the # gate's notices) never count toward an AGENT's pending budget — neither the @@ -448,6 +449,9 @@ def _insert_values( question = item.get("question") if question and len(str(question).encode("utf-8")) > _DB_BELT_QUESTION_MAX_BYTES: raise ValueError(f"operator-queue 'question' exceeds {_DB_BELT_QUESTION_MAX_BYTES} bytes") + item_type = item.get("type") + if item_type and len(str(item_type).encode("utf-8")) > _DB_BELT_TYPE_MAX_BYTES: + raise ValueError(f"operator-queue 'type' exceeds {_DB_BELT_TYPE_MAX_BYTES} bytes") options_json = json.dumps(item.get("options")) if item.get("options") else None context_json = json.dumps(item.get("context")) if item.get("context") else None diff --git a/src/backend/services/operator_queue_service.py b/src/backend/services/operator_queue_service.py index 013c51d30..ba89afd55 100644 --- a/src/backend/services/operator_queue_service.py +++ b/src/backend/services/operator_queue_service.py @@ -90,6 +90,10 @@ OPERATOR_QUEUE_QUESTION_MAX = int(os.getenv("OPERATOR_QUEUE_QUESTION_MAX", "4000")) OPERATOR_QUEUE_CONTEXT_MAX_BYTES = int(os.getenv("OPERATOR_QUEUE_CONTEXT_MAX_BYTES", "8192")) OPERATOR_QUEUE_OPTIONS_MAX_BYTES = int(os.getenv("OPERATOR_QUEUE_OPTIONS_MAX_BYTES", "4096")) +# #3385: `type` is agent-authored free text. A plain constant, not an env knob: +# it must stay above every platform type literal (the longest is 24 chars) or the +# clamp would re-type a platform row. +OPERATOR_QUEUE_TYPE_MAX = 64 # #3243: the authoring caps on an agent's ask, refused (never truncated) on the # native path and held on the queue-file path. Floored at load so a mis-set env # cannot refuse every skill-gate approval (two short options) or every title. @@ -675,6 +679,13 @@ def _truncate_with_marker(text: str, max_len: int) -> str: return text[:keep] + _TRUNC_MARKER +def _bounded_type(value): + """#3385: the ingest bound on an agent-authored `type` — truncate-with-marker, + strings only. Shared by the clamp and the #2915 fingerprint so a clamped + row never reads as a rewrite of its own oversize entry.""" + return _truncate_with_marker(value, OPERATOR_QUEUE_TYPE_MAX) if isinstance(value, str) else value + + def _scalar_text(value): """#3314: a JSON number/bool title or question is stored as its text, so the stored value is the same on every DB backend and the fingerprint can match it. @@ -853,6 +864,7 @@ def _clamp_ingested_item(req: dict, agent_name: str = "") -> dict: contract above still holds. - title / question: truncate-with-marker. + - type: truncate-with-marker (#3385). - context: non-dict → {} (fixes the create_item .get crash class); serialized > cap OR non-serializable → a marker object that preserves only a *valid* (≤EXECUTION_ID_MAX, id-shaped) execution_id. @@ -875,6 +887,9 @@ def _clamp_ingested_item(req: dict, agent_name: str = "") -> dict: if isinstance(question, str): out["question"] = _truncate_with_marker(question, OPERATOR_QUEUE_QUESTION_MAX) + if "type" in out: + out["type"] = _bounded_type(out["type"]) + # ent#364: the addressee is an authorization decision, so it is resolved here # rather than trusted. Absent/invalid/off-roster → None, i.e. an operator ask. # @@ -1097,6 +1112,9 @@ def _row_content(row: dict) -> dict: def _comparable_type(value) -> str: + # #3385: bound BEFORE the empty-value default, so a row holding the clamped + # value and an entry still holding the raw one compare equal. + value = _bounded_type(value) return "question" if value in (None, "") else str(value) @@ -2322,7 +2340,8 @@ async def _sync_agent(self, agent_name: str): agent_name, {"id": new_id, "sync_state": None, "sync_detail": None}, SYNC_CONFIRMED, None, now, ) - # `type` is agent-authored free text the clamp does not bound; the + # `type` is agent-authored free text — the clamp bounds its length + # (#3385) but not its content; the # audit row is durable operator-visible state (G-04), so it carries # the folded token or `other` — never the string itself. `req_id` # passed the `_ID_RE` shape check above. diff --git a/tests/registry.json b/tests/registry.json index 547ce41ed..c20268910 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -1822,7 +1822,7 @@ "security", "reliability" ], - "description": "Operator-queue create-path ingestion caps (#1632): the agent-authored sync boundary is bounded by a DB-measured pending-DEPTH cap (primary, Redis-independent), a per-agent + fleet RATE cap (fail-open, break on deny, real in-process-fallback exercise), a total truncate-with-marker field-hygiene clamp inside the #1525 try/except (title/question/context/options/execution_id/created_at/priority, non-dict context, boundary lengths, never-raises), the reserved-platform-id + malformed-id guard, the opqueue:leader cross-worker lock (non-leader poll cycle is a no-op), the one-per-episode flood alert (un-guessable id, cooldown, emit-failure-safe, platform-exempt), the oversize-file skip, and the generous DB-sink belt + count_pending_for_agent helper. Also pins the concrete platform producer validation_service._notify_operator_on_failure, converted to a direct db.create_operator_queue_item (#1632): reserved `val_` id (an agent can't pre-create and thereby suppress its own validation alarm), bypasses the agent-file sync caps, and stays best-effort (a create failure never fails validation) — the conversion also fixed a latent bug where the notification appended to a bare list and was never ingested. Also pins the _leader_ttl 30s floor (F1: lease outlasts one worst-case cycle + sleep, so leadership doesn't flap and the flood alert doesn't double-emit under --workers 2). Pure/mocked (rate_limiter stubbed + one real in-process test; DB engine stubbed)." + "description": "Operator-queue create-path ingestion caps (#1632): the agent-authored sync boundary is bounded by a DB-measured pending-DEPTH cap (primary, Redis-independent), a per-agent + fleet RATE cap (fail-open, break on deny, real in-process-fallback exercise), a total truncate-with-marker field-hygiene clamp inside the #1525 try/except (title/question/context/options/execution_id/created_at/priority, non-dict context, boundary lengths, never-raises), the reserved-platform-id + malformed-id guard, the opqueue:leader cross-worker lock (non-leader poll cycle is a no-op), the one-per-episode flood alert (un-guessable id, cooldown, emit-failure-safe, platform-exempt), the oversize-file skip, and the generous DB-sink belt + count_pending_for_agent helper. Also pins the concrete platform producer validation_service._notify_operator_on_failure, converted to a direct db.create_operator_queue_item (#1632): reserved `val_` id (an agent can't pre-create and thereby suppress its own validation alarm), bypasses the agent-file sync caps, and stays best-effort (a create failure never fails validation) — the conversion also fixed a latent bug where the notification appended to a bare list and was never ingested. Also pins the _leader_ttl 30s floor (F1: lease outlasts one worst-case cycle + sleep, so leadership doesn't flap and the flood alert doesn't double-emit under --workers 2). Also pins #3385: an agent-authored `type` is truncated with the marker at OPERATOR_QUEUE_TYPE_MAX (64) while every platform-emitted and ask_operator type passes unchanged, the #2915 fingerprint bounds both sides so a clamped row is not a rewrite of its own oversize entry, and the DB belt rejects a type over 1 KiB. Pure/mocked (rate_limiter stubbed + one real in-process test; DB engine stubbed)." }, { "file": "unit/test_1615_ssh_password_removed.py", diff --git a/tests/unit/test_1632_operator_queue_caps.py b/tests/unit/test_1632_operator_queue_caps.py index 5c04f7cb9..feb78c709 100644 --- a/tests/unit/test_1632_operator_queue_caps.py +++ b/tests/unit/test_1632_operator_queue_caps.py @@ -38,6 +38,7 @@ OperatorQueueSyncService, _clamp_ingested_item, _truncate_with_marker, + changed_fields, _valid_execution_id, _TRUNC_MARKER, _OPTIONS_DROPPED_MARKER, @@ -46,6 +47,7 @@ OPERATOR_QUEUE_CONTEXT_MAX_BYTES, OPERATOR_QUEUE_MAX_PENDING_PER_AGENT, ) +from services.ask_service import ASK_TYPES # noqa: E402 from services.rate_limiter import RateLimitResult # noqa: E402 pytestmark = pytest.mark.unit @@ -344,6 +346,73 @@ def test_clamp_never_raises_on_hostile_shapes(self): _clamp_ingested_item(bad) # must not raise + +# --------------------------------------------------------------------------- +# #3385: `type` is agent-authored free text — bounded at ingest like title. +# --------------------------------------------------------------------------- + +# The type literals the platform itself writes, the longest first. A bound that +# altered one of these would re-type a platform row. +_PLATFORM_TYPES = sorted( + set(oqs._BUDGETED_ALERT_TYPES) | { + "workspace_problem_report", "cost_limit_approaching", "cost_limit_exceeded", + "operator_queue_sync", "watchdog_recovery", "monitoring_alert", "queue_flood", + }, + key=len, reverse=True, +) + + +class TestClampType: + def test_oversize_type_shortened_with_marker(self): + out = _clamp_ingested_item({"id": "x", "type": "x" * 100_000}) + assert len(out["type"]) == oqs.OPERATOR_QUEUE_TYPE_MAX + assert out["type"].endswith(_TRUNC_MARKER) + + def test_one_over_cap_gets_marker(self): + out = _clamp_ingested_item({"id": "x", "type": "y" * (oqs.OPERATOR_QUEUE_TYPE_MAX + 1)}) + assert len(out["type"]) == oqs.OPERATOR_QUEUE_TYPE_MAX + assert out["type"].endswith(_TRUNC_MARKER) + + @pytest.mark.parametrize( + "value", list(_PLATFORM_TYPES) + list(ASK_TYPES) + ["t" * 64]) + def test_legit_types_unchanged(self, value): + assert _clamp_ingested_item({"id": "x", "type": value})["type"] == value + + def test_platform_types_fit_the_cap(self): + # The cap must stay above every platform literal, or the clamp re-types one. + assert len(_PLATFORM_TYPES[0]) < oqs.OPERATOR_QUEUE_TYPE_MAX + + def test_missing_type_stays_missing(self): + # Absent ⇒ absent, so create_item's "question" default still applies. + assert "type" not in _clamp_ingested_item({"id": "x", "title": "t"}) + + def test_non_str_type_passes_through(self): + assert _clamp_ingested_item({"id": "x", "type": 7})["type"] == 7 + + +_RAW_TYPE = "approval" + "z" * 100_000 + + +@pytest.mark.parametrize("row_type, entry_type", [ + # The row stores the clamped value; the agent's file still holds the raw one. + (_truncate_with_marker(_RAW_TYPE, 64), _RAW_TYPE), + # A row ingested before #3385 stored the raw value: it still compares equal. + (_RAW_TYPE, _RAW_TYPE), + # A non-str type on both sides is not a rewrite either. + (5, 5), +], ids=["clamped-row-vs-raw-entry", "legacy-raw-row", "int-type"]) +def test_3385_clamped_type_is_not_a_rewrite(row_type, entry_type): + row = {"title": "t", "question": "q", "type": row_type} + entry = {"title": "t", "question": "q", "type": entry_type} + assert changed_fields(row, entry) == [] + + +def test_3385_rewrite_inside_the_kept_prefix_is_still_a_rewrite(): + row = {"title": "t", "question": "q", "type": _truncate_with_marker(_RAW_TYPE, 64)} + entry = {"title": "t", "question": "q", "type": "question" + "z" * 100_000} + assert changed_fields(row, entry) == ["type"] + + class TestClampBoundaries: def test_title_exactly_at_cap_no_marker(self): exact = "T" * OPERATOR_QUEUE_TITLE_MAX @@ -731,6 +800,18 @@ def test_belt_passes_a_small_platform_item(self, monkeypatch): # the re-read row's platform uuid (not the agent's request_id). assert rid == _STUB_ROW_ID + def test_belt_rejects_oversize_type(self): + # #3385: a caller that skips the ingest clamp still can't store a huge type. + ops = _ops() + with pytest.raises(ValueError, match="'type' exceeds"): + ops.create_item("agent", {"id": "x", "type": "t" * 2_000}) + + def test_belt_passes_64_four_byte_chars_of_type(self, monkeypatch): + # A clamped type at the cap is at most 64 chars; 4-byte chars are 256 bytes. + _patch_engine(monkeypatch, _FakeEngine()) + ops = _ops() + assert ops.create_item("agent", {"id": "x", "type": "\U0001d11e" * 64}) == _STUB_ROW_ID + def test_missing_id_still_raises_valueerror(self): # #1525 belt preserved. ops = _ops()