From b5d62eaf00eb8165e2bae152513402362df7d6e8 Mon Sep 17 00:00:00 2001 From: "Trinity Agent (trinity)" Date: Thu, 8 Oct 2026 18:54:02 -0400 Subject: [PATCH 1/5] fix(operator-queue): falsy options and non-string title no longer read as a rewrite (#3314) The #2915 sync fingerprint must reproduce what ingest stored. Two seams diverged, so an untouched agent entry read SYNC_CHANGED and every operator answer was refused 409 item_diverged: - options: the store keeps a falsy value ([], "", {}, 0, False) as NULL, but `_canonical_options` serialised it ("[]"). It now canonicalises any falsy value like None, mirroring the store's truthiness. - title / question: a JSON number or bool passed the clamp untouched and was stored verbatim (read back backend-dependently: SQLite True -> "1", PG "true"), while `_entry_content` fingerprinted it as the default. A new `_scalar_text` stores it as its text ("123", "True") in the clamp and mirrors that in `_entry_content`, so both sides match on every backend. Not covered: no number conversion before the #3243 title cap check (a numeric title is still never cap-held; scope kept narrow). `type` handling untouched (#3385). Stale rows: asks stored before this fix with a boolean title hold "1" on SQLite and stay flagged as diverged; integer-titled rows recover on the next sync. No migration. Tests: the strict xfails r88-r91 in test_ec_operator_queue_edges.py are removed and r90b (boolean title) added. With the fix absent all five go red: r88/r89 `['options'] == []`, r90/r90b `['title'] == []`, r91 `['question'] == []`. Co-Authored-By: Claude Opus 5.5 --- .../services/operator_queue_service.py | 24 +++++++++++++++---- tests/unit/test_ec_operator_queue_edges.py | 11 +++------ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/backend/services/operator_queue_service.py b/src/backend/services/operator_queue_service.py index bc38ac14f..84113d459 100644 --- a/src/backend/services/operator_queue_service.py +++ b/src/backend/services/operator_queue_service.py @@ -675,6 +675,16 @@ def _truncate_with_marker(text: str, max_len: int) -> str: return text[:keep] + _TRUNC_MARKER +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. + Anything else is returned unchanged (str, None, and containers — a non-empty + container still fails the insert, as today).""" + if isinstance(value, (bool, int, float)): + return str(value) + return value + + def _validated_addressee(agent_name: str, raw) -> Optional[str]: """The email an ask is addressed to, or None (ent#364). @@ -852,11 +862,11 @@ def _clamp_ingested_item(req: dict, agent_name: str = "") -> dict: """ out = dict(req) - title = out.get("title") + title = _scalar_text(out.get("title")) if isinstance(title, str): out["title"] = _truncate_with_marker(title, OPERATOR_QUEUE_TITLE_MAX) - question = out.get("question") + question = _scalar_text(out.get("question")) if isinstance(question, str): out["question"] = _truncate_with_marker(question, OPERATOR_QUEUE_QUESTION_MAX) @@ -1027,7 +1037,11 @@ def _normalise_expires(value) -> str: def _canonical_options(options) -> str: - if options is None: + # #3314: mirrors `db/operator_queue.py` storing `json.dumps(options) if + # item.get("options") else None` — a falsy value ([], "", {}, 0, False) is + # stored NULL, so it must canonicalise like None or the untouched entry + # reads as a rewrite. + if not options: return "" try: return json.dumps(options, sort_keys=True, separators=(",", ":"), ensure_ascii=False) @@ -1041,9 +1055,9 @@ def _entry_content(req: dict) -> dict: matches what the row stored at ingest byte for byte. `_clamp_ingested_item` itself is deliberately not called: it reads the roster and may create a workspace thread, and must never run speculatively.""" - title = req.get("title") + title = _scalar_text(req.get("title")) title = _truncate_with_marker(title, OPERATOR_QUEUE_TITLE_MAX) if isinstance(title, str) else None - question = req.get("question") + question = _scalar_text(req.get("question")) question = _truncate_with_marker(question, OPERATOR_QUEUE_QUESTION_MAX) if isinstance(question, str) else None options = req.get("options") if options is not None: diff --git a/tests/unit/test_ec_operator_queue_edges.py b/tests/unit/test_ec_operator_queue_edges.py index 5db4b8e63..7ecbe5f29 100644 --- a/tests/unit/test_ec_operator_queue_edges.py +++ b/tests/unit/test_ec_operator_queue_edges.py @@ -323,15 +323,12 @@ def test_an_untouched_entry_is_unchanged(self, real_db, over): row = _ingest_and_read_back(real_db, entry) assert oqs.changed_fields(row, entry) == [] - @pytest.mark.xfail(strict=True, reason="#3314: " + BUG.format( - "a falsy non-null `options` ([] / \"\") is stored NULL, so the untouched entry reads " - "`changed:options` and every answer is refused 409 item_diverged")) @pytest.mark.parametrize("options", [ pytest.param([], id="r88-options-empty-list"), pytest.param("", id="r89-options-empty-string"), ]) def test_a_falsy_options_value_is_not_a_rewrite(self, real_db, options): - """REACHABILITY: `_sync_agent` ingests the entry (clamp keeps `[]` — + """Regression for #3314. `_sync_agent` ingests the entry (clamp keeps `[]` — `options is not None` and it is under the byte cap), then `db/operator_queue.py:426` stores `json.dumps(options) if options else None` → NULL. Next cycle `changed_fields` compares `_row_content` @@ -348,15 +345,13 @@ def test_a_falsy_options_value_is_not_a_rewrite(self, real_db, options): row = _ingest_and_read_back(real_db, entry) assert oqs.changed_fields(row, entry) == [] - @pytest.mark.xfail(strict=True, reason="#3314: " + BUG.format( - "a non-string title/question is stored verbatim but fingerprinted as the default, " - "so the untouched entry reads `changed`")) @pytest.mark.parametrize("over", [ pytest.param({"title": 123}, id="r90-title-int"), pytest.param({"question": 5}, id="r91-question-int"), + pytest.param({"title": True}, id="r90b-title-bool"), ]) def test_a_non_string_title_is_not_a_rewrite(self, real_db, over): - """REACHABILITY: same seam as above; `_clamp_ingested_item` only + """Regression for #3314. Before the fix `_clamp_ingested_item` only truncates `str` titles and passes others through, `_insert_values` stores `item.get("title") or "Agent request"` (the int survives), while `_entry_content` maps any non-str to None → "Agent request". Lower From 923b1edceb14339f0133904498b604700e4d60f2 Mon Sep 17 00:00:00 2001 From: "Trinity Agent (trinity)" Date: Thu, 8 Oct 2026 18:54:57 -0400 Subject: [PATCH 2/5] fix(operator-queue): widen the fingerprint round-trip property to falsy options and non-string titles (#3314) The Tier-2 real-SQLite property (entry -> clamp -> store -> sync read -> changed_fields == []) excluded the #3314 inputs while they were strict xfails. It now generates falsy options ([], "", {}, 0, False) and int/bool/float(nan, inf)/[]/{} titles and questions, with explicit examples for `options: []` and `title: 123`. This is the parity guard for the fingerprint mirroring the store's options truthiness. Only the null `type` exclusion (#3315/#3385) remains. With the #3314 service fix reverted the property goes red (`['title', 'question'] == []` on the explicit examples). Co-Authored-By: Claude Opus 5.5 --- .../unit/test_ec_operator_queue_properties.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_ec_operator_queue_properties.py b/tests/unit/test_ec_operator_queue_properties.py index 5905e668c..8de4a087f 100644 --- a/tests/unit/test_ec_operator_queue_properties.py +++ b/tests/unit/test_ec_operator_queue_properties.py @@ -17,10 +17,11 @@ FRESH agent name (rows never collide, nothing is deleted); the fixture asserts the engine points at the per-process unit-test SQLite. - The strategy EXCLUDES the inputs of the three strict-xfail bugs in the edges - file (falsy-but-not-null `options`, non-string `title`/`question`, a null - `type`) — they are pinned there with reachability evidence; leaving them in - would make this property permanently red instead of guarding the rest. + The strategy EXCLUDES the input of the remaining strict-xfail bug in the + edges file (a null `type`, #3315/#3385) — it is pinned there with + reachability evidence; leaving it in would make this property permanently + red instead of guarding the rest. Falsy-but-not-null `options` and + non-string `title`/`question` (#3314) are fixed and generated here. """ from __future__ import annotations @@ -328,13 +329,15 @@ def real_db(): short = st.text(max_size=150) +non_str_text = st.one_of(st.integers(-10**6, 10**6), st.booleans(), + st.floats(allow_nan=True, allow_infinity=True), st.just([]), st.just({})) entries = st.fixed_dictionaries( {"status": st.just("pending")}, optional={ - "title": st.one_of(st.none(), short, st.text(min_size=290, max_size=320)), - "question": st.one_of(st.none(), short), - # falsy-non-null options are the r88/r89 xfail — excluded here - "options": st.one_of(st.none(), st.lists(st.text(max_size=20), min_size=1, max_size=5), + # #3314: non-string scalars and empty containers are generated too + "title": st.one_of(st.none(), short, st.text(min_size=290, max_size=320), non_str_text), + "question": st.one_of(st.none(), short, non_str_text), + "options": st.one_of(st.none(), st.sampled_from([[], "", {}, 0, False]), st.lists(st.text(max_size=20), min_size=1, max_size=5), st.lists(st.dictionaries(st.text(max_size=5), st.integers(), max_size=2), min_size=1, max_size=3), st.just(["x" * 5000])), @@ -360,6 +363,8 @@ def real_db(): @example(entry={"status": "pending", "context": {"workspace_session_id": "forged", "k": 1}}) @example(entry={"status": "pending", "context": {"blob": "y" * 9000, "execution_id": "e-1"}}) @example(entry={"status": "pending", "title": "t" * 310}) +@example(entry={"status": "pending", "options": []}) +@example(entry={"status": "pending", "title": 123}) def test_an_untouched_entry_never_reads_as_changed(real_db, entry): agent = f"ec-oq-prop-{next(_seq)}-{os.getpid()}" entry = {"id": "p-1", **entry} From f9471d04458b63f329b103daf14cfb25be78af70 Mon Sep 17 00:00:00 2001 From: "Trinity Agent (trinity)" Date: Thu, 8 Oct 2026 19:46:45 -0400 Subject: [PATCH 3/5] fix(operator-queue): bound the agent-authored `type` at ingest and the DB sink (#3385) `_clamp_ingested_item` bounded title, question, context and options but copied an agent's free-text `type` straight through. A queue-file entry could therefore store, and broadcast, a multi-MB type. - `_bounded_type`: truncate-with-marker at OPERATOR_QUEUE_TYPE_MAX = 64, strings only. A fixed constant: every platform type literal (the longest is 24 chars) and every ASK_TYPES value passes unchanged. - The clamp applies it. `_comparable_type` applies the same bound before the empty-value default, so the #2915 fingerprint does not report a clamped row as `changed` against its own raw entry. Without that, responses would be refused with 409 `item_diverged`. - DB belt: `_insert_values` raises ValueError for a type over 1 KiB (`_DB_BELT_TYPE_MAX_BYTES`), like the id/title/question/context belts, so a caller that skips the clamp is quarantined (#1525). - Reworded the `ingested` audit comment that said the clamp does not bound `type`. Stacked on feature/3314-queue-fingerprint-falsy (923b1edc). Mutation record (fix reverted on a scratch copy, then restored byte-identical): - clamp line removed: TestClampType::test_oversize_type_shortened_with_marker and ::test_one_over_cap_gets_marker go red - `_comparable_type` bound removed: test_3385_clamped_type_is_not_a_rewrite [clamped-row-vs-raw-entry] goes red - belt removed: TestDbBelt::test_belt_rejects_oversize_type goes red Co-Authored-By: Claude Opus 5.5 --- src/backend/db/operator_queue.py | 6 +- .../services/operator_queue_service.py | 21 ++++- tests/registry.json | 2 +- tests/unit/test_1632_operator_queue_caps.py | 81 +++++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) 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 84113d459..faf345ea1 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. @@ -848,6 +859,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. @@ -870,6 +882,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. # @@ -1092,6 +1107,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) @@ -2317,7 +2335,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 59ac4b834..853281553 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -1811,7 +1811,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() From eab91c10747e5aa4f09d6aa1e1f41adb28151a62 Mon Sep 17 00:00:00 2001 From: "Trinity Agent (trinity)" Date: Thu, 8 Oct 2026 20:15:50 -0400 Subject: [PATCH 4/5] docs(operator-queue): the ingest clamp and DB belt lists name type (#3385) The three docs that enumerate the _clamp_ingested_item truncate-with-marker fields and the create_item DB belt now name type: 64 characters at ingest (OPERATOR_QUEUE_TYPE_MAX, with the truncation marker) and 1 KiB at the belt. Co-Authored-By: Claude Opus 5.5 --- docs/memory/architecture/api-endpoints.md | 2 +- docs/memory/feature-flows/operating-room.md | 4 ++-- docs/memory/requirements/security.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) 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. From bffddc0936f948e66e91c6f82bf9075786e04bee Mon Sep 17 00:00:00 2001 From: Eugene Vyborov <1073874+vybe@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:33:47 +0100 Subject: [PATCH 5/5] fix(operator-queue): a falsy scalar title keeps the stored default (#3314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merge-train: mechanical, per the merge-train note on the PR. `_scalar_text(0)` returned "0", but `create_item` stores the `or`-default ("Agent request") for a falsy title/question, and every row ingested before this fix carries that default — so the entry fingerprint "0" diverged from the row and flipped such rows to 409 `item_diverged` on the next sync. Only truthy scalars are stringified now; a 0/False title keeps displaying the default as before. Co-Authored-By: Claude Fable 5.1 --- src/backend/services/operator_queue_service.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/backend/services/operator_queue_service.py b/src/backend/services/operator_queue_service.py index 84113d459..013c51d30 100644 --- a/src/backend/services/operator_queue_service.py +++ b/src/backend/services/operator_queue_service.py @@ -679,8 +679,13 @@ 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. Anything else is returned unchanged (str, None, and containers — a non-empty - container still fails the insert, as today).""" - if isinstance(value, (bool, int, float)): + container still fails the insert, as today). A FALSY scalar (0, 0.0, False) + is also returned unchanged: `create_item` stores the `or`-default for it + ("Agent request" / "(no details provided)"), and rows ingested before this + fix carry that default too, so stringifying it here would fingerprint "0" + against a row that says "Agent request" and flip every such pre-fix row to + 409 `item_diverged` on the next sync.""" + if isinstance(value, (bool, int, float)) and value: return str(value) return value