Skip to content

feat(operator-queue): agents raise asks natively — ask_operator, receipts, re-asks (abilityai/trinity-enterprise#611, PR B) - #3028

Merged
vybe merged 6 commits into
devfrom
feature/ent611-native-ask
Sep 29, 2026
Merged

vybe merged 6 commits into
devfrom
feature/ent611-native-ask

Conversation

@webmixgamer

@webmixgamer webmixgamer commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Description

An agent now asks a person for a decision through one platform call instead of appending to ~/.trinity/operator-queue.json. The call is validated at once, stored, announced and answered with a receipt. This is PR B of the ent#611 ask object; PR A (#3023, merged to dev) recorded how an ask ends.

  • One call, as the agent itself. POST /api/agents/{name}/operator-queue and the MCP tool ask_operator are self-acting: the agent comes from the key, never from a parameter. Every other principal gets 403 agent_identity_required. Ephemeral agents stay fenced and keep the file.
  • ask_service.raise_ask is the create entry of PR A's sink, and the seam the gate path (abilityai/trinity-enterprise#164) will call with raised_by="gate", channel="gate" and a gate- id. In order:
    1. static validation, where every refusal is a named 422. The id namespace depends on the raiser: an agent may not use a platform-reserved prefix (gate- included), and a gate raise must use gate-;
    2. replay — the same request_id returns the first receipt (status: replayed, HTTP 200, plus differs), before every time-dependent check;
    3. the bug: operator-queue create path has no rate limit or size caps — flooding/operator-fatigue surface (blocking pull default-ON) #1632 rate buckets, before every check that reads the database, so a refused ask spends a token (a replay spends none);
    4. the 15-minute deadline floor;
    5. the re-ask link: supersedes_expired must name the agent's own expired ask, and repeating an expired proposal without it is 422 reask_requires_link;
    6. role addressing: a provider first, through an optional people_for, where an empty answer means nobody fills the role (a primary ask then goes to the operators); with no provider, primary → the owner and operator → no person. approver / viewer are refused until someone fills them;
    7. an atomic per-agent create: the replay check, depth cap and insert run under one lock (PostgreSQL pg_advisory_xact_lock, SQLite BEGIN IMMEDIATE), and a file entry that races the insert comes back as a replay;
    8. one raised audit row attributed to the agent, plus a thin operator_queue_new.
  • The receipt carries status, id, request_id, raised_by, channel, type, to_role, resolved, ask_status, disposition, disposed_at, expires_at, wakes_on_ending and supersedes_expired, plus differs on a replay. It names the role, never an email. The reads keep the same rule for machine keys.
  • Native rows sit outside the file contract. The sync index lists them as foreign, and the poller skips a file entry that re-uses a native id before any branch reads it. The write-backs and sweeps never touch a native row, and Clear All hides one once it is answered.
  • The file channel stays for two releases.
    • The poller creates through create_operator_queue_item_with_outcome, and never counts, announces or audits a row it did not insert.
    • It logs the file's deprecation once per agent per process, naming ask_operator.
  • The prompt (Operator Communication) leads with ask_operator / get_my_ask by their bare names, with the file as the fallback. The meta-prompt reference copy and the agent guide mirror it.
  • The Operations cards link a re-ask and the expired ask it re-raises: Re-ask of … on the new ask, Re-asked as … on the expired one.
  • Every card that offers the decision shows the proposal, read-only and expanded: the Operations card, the /m ops card and the Workspace ask (the WorkspaceAsk projection now names proposal; context stays off).
  • No migration. PR A added all twelve columns.

Design decisions beyond the approved plan

  • differs is computed in the sink, on the native path only (the plan's MCP-specific comparator). Deadlines are compared after the store's own normalisation, whatever offset the agent used.
  • resolved is true for an operator ask and for an ask resolved to a person. It is false when the owner has no email, in which case the ask goes to the operators.
  • Several people from a provider are recorded in resolved_to, but none of them becomes the single Workspace addressee.
  • The receipt's supersedes_expired is the predecessor's request_id; the column stores the predecessor's uuid.
  • context / proposal are declared object-or-null in the MCP tool. fastmcp publishes every tool through xsschema's strictJsonSchema, which would publish a plain z.record as an object that accepts no keys. null reads as absent.

Changed in review on this PR (2026-09-28, each with a failing test first)

  • The gate's reserved prefix (d929c00f8): gate- joins _RESERVED_ID_PREFIXES. A gate raise must use it (422 invalid_request_id otherwise), and an agent's raise may not (422 reserved_request_id). So no agent can pre-create a gate id, and is_platform_minted keeps both wakes off gate rows. The receipt carries raised_by; an unknown raiser or channel is a ValueError.
  • A provider's empty answer for primary goes to the operators with resolved: false (the ent#606 fallback), not to the owner.
  • The rate check runs right after the replay, before the re-ask scan and the owner lookup read the database.
  • A racing file entry meets the native insert's ON CONFLICT DO NOTHING, and the call returns replayed, not a 500. Pinned on both backends.
  • get_my_ask names a re-ask's predecessor by request_id, like the receipt.
  • OPERATOR_QUEUE_PROPOSAL_MAX_BYTES is wired into all three compose files and .env.example (a4e77c974).
  • The proposal is rendered on every card that offers the decision (a13880487): components/operator/QueueProposal.vue with rows from utils/operatorQueue.js::proposalRows, as text only.
  • New coverage: the real owner lookup, and a real minted key through the route (key resolution, identity, the ephemeral fence).

Second pass, after the approval (06737d749, df1ea4cd1):

  • test_an_agent_may_not_author_a_gate_id has its own agent and ids. It was order-dependent and failed on 2 of the 3 nightly seeds. The file now passes under 20 seeds.
  • The race tests install their holds in create_native_item.__globals__. A sibling test's stand-in for db.operator_queue had been disabling them in full-suite runs, where the cap test passed even without the lock.
  • wakes_on_ending is false on gate rows.
  • channel == "gate" if and only if raised_by == "gate".
  • A gate raise never replays a row the gate didn't raise (409 request_id_taken).
  • The re-ask guard and the re-ask link stay with their raiser (raised_by filter; a row older than the column is the agent's, a platform alarm is nobody's).
  • The Resolved card shows the proposal.

Fixed during /review and /cso --diff (each with a failing test first)

  • Audit attribution: the raised row recorded the agent's raise as the owner acting in person, because the resolver ranks a user first. It now follows the ent#614 rule: no actor_user, and the key and the owner's email carried explicitly. The test asserts the real resolver's answer, ("agent", <agent>, None).
  • Race tests: the tests could not lose the race — with the PostgreSQL lock removed they still passed 3 runs out of 3.
    • Each create is now held inside its critical section.
    • A new requires_postgres module pins the cap, concurrent retries, per-agent isolation and the outcome accessor, and schema-parity CI runs it on PostgreSQL.
    • Removing either backend's lock turns that backend red on every run.
  • Addressee reads: addressed_to_email on a native row holds the person the platform resolved, and machine keys could read it back.
    • It was verified live, then refuted as a new disclosure, because GET /api/users/me already returns the owner's email to the same key.
    • The fix is kept as hardening: the field is withheld like resolved_to. This closes the path a people_for provider opens, once a role can resolve to someone other than the owner.
  • Published schema: the live schema said context / proposal accept no keys. They are now object-or-null, pinned over listTools on the real transport.

Security report: docs/security-reports/cso-diff-2026-09-25-ent611-native-ask.md (no open findings).

Related Issue

Refs abilityai/trinity-enterprise#611 (PR B of 2; PR A is #3023). The issue's gate-initiated criterion is delivered by abilityai/trinity-enterprise#164 through the raise_ask(raised_by="gate") seam.

Journey Impact

Journey Impact: extends: J05

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Testing

  • I have tested this locally
  • New tests added (if applicable)
  • All existing tests pass
  • Every new test executes the changed path

Backend.

MCP. operator_queue.test.ts and access-wiring.test.ts (31 and 15 tests) cover:

  • identity from the key;
  • that only the declared fields travel;
  • named refusals;
  • the published schema, over the real transport.

The whole suite is 567 passed, and tsc is clean.

Frontend. operatorQueueReask.spec.js (mounted, 9 tests), and operatorQueueProposal.spec.js, portalAskProposal.spec.js and mobileAdminProposal.spec.js (mounted, 12 tests). The whole unit suite is 3,684 passed. Two unrelated specs cannot load on the host, because @vue/compiler-sfc is not installed there; CI has it. The raw-colour, loading-gate and source-text ratchets are green with no baseline edits.

Mutations (each red, and each file restored byte-identically):

  • Poller:
    • no foreign skip;
    • inserted ignored;
    • the deprecation notice before the insert check;
    • the plain create.
  • MCP:
    • a fixed agent;
    • raw params forwarded;
    • no refusal parse.
  • Frontend: each card's call site loses the store's items.
  • Locks: either backend's lock removed.
  • Store: the rowcount ignored.
  • Replay: the deadline normalisation removed from differs.
  • Review round (20 more):
    • the gate- reservation, the gate branch, and the call site that passes raised_by;
    • the [] fallback, the receipt field, the rate order and the raiser/channel check;
    • the conflict clause, the replayed outcome and the readback;
    • the owner lookup, the ephemeral fence and the identity check;
    • the three card wirings, proposalRows and v-html;
    • the projection, both dropping proposal and forwarding context.
  • Second pass (12 more):
    • the gate row's wake, the channel pin, the replay rule and both of its call sites;
    • the predecessor's raiser check, the raiser passed to the re-ask scan at its call site and inside the scan, and the database filter;
    • both branches of the legacy-row rule;
    • the Resolved card wiring.

Live walk-through

Done on a local instance before this PR opened:

  • ask_operator over the real MCP server with an agent's own key: an approval to primary was created; an alert went to the operators; a deadline under the floor was refused invalid_expires_at.
  • The expired ask ended through the real poller sweep as timeout.
  • Repeating its proposal without the link was refused reask_requires_link; with the link it was created; an identical retry replayed with differs: [].
  • A person's key calling ask_operator was refused before any backend call.
  • Nothing native reached the agent's queue file.
  • The operator eyeballed the cards.

Merge notes

Checklist

  • My code follows the project's style guidelines
  • I have updated the documentation (if applicable):
    • requirements security.md §26.10 OPS-001-RAISE (§26.1, §26.6 and §26.9 point to it);
    • the operating-room flow ("Raising an ask") and the flows index;
    • architecture: api-endpoints.md, mcp-server.md, background-services.md, backend.md, database.md, frontend.md, workspace.md;
    • user docs: approvals, operating-room, the MCP server page, the MCP FAQ;
    • four learnings fragments.
  • I have not committed any sensitive data (API keys, credentials, etc.)
  • I have added appropriate logging for new functionality

🤖 Generated with Claude Code

@webmixgamer webmixgamer added the ui PR touches the frontend UI — triggers Playwright e2e tests label Sep 25, 2026
@webmixgamer

Copy link
Copy Markdown
Contributor Author

/review Report

Branch: feature/ent611-native-ask → feature/ent611-ask-object (stacked on #3023; merge-base dfcb7c07b)
Files Changed: 47 (+2,934 / −142)
Scope: CLEAN
Plan Completion: 18 done / 0 partial / 0 not done / 2 changed / 0 unverifiable

Scope

  • Intent: ent#611's agent-initiated half. An agent asks for a decision through one platform call, validated at the call and answered with a receipt. Retries are idempotent. The file stays for two releases, logging a deprecation. There is one audit trail. Re-asks link their expired predecessor.
  • Delivered:
    • ask_service.raise_ask, behind POST /api/agents/{name}/operator-queue and MCP ask_operator;
    • an atomic per-agent create;
    • native rows outside the file contract;
    • the poller's compatibility changes;
    • the prompt, meta-prompt and agent guide;
    • re-ask badges and docs.
  • CHANGED:

Execution coverage (Step 2.5)

changed symbol / test file executed by live consumer verdict
create_native_item + _lock_agent_for_create test_ent611_native_ask.py (forced overlap), test_ent611_native_ask_pg.py (SQLite + PostgreSQL) raise_ask → database.create_native_operator_queue_item ✅ lock removed → red on each backend, 3/3
create_item_with_outcome TestFileCreateWithOutcome, pg module poller _sync_agent ✅ rowcount ignored → red on both backends
sync-index foreign, _file_contract() filters TestFileContractExclusion (real DB) poller reconcile, write-backs, sweeps, Clear All ✅
raise_ask + helpers TestRaiseAsk, TestReplay, TestReask, TestCapsAndAnnouncement (real DB) raise_my_ask ✅
resolve_role_people provider tests ask_service._address ✅
raise_my_ask + OperatorAskCreate TestRaiseRoute (TestClient; the dependant-tree override) MCP client.raiseAsk ✅
poller: foreign skip, outcome, deprecation TestThePoller*, TestTheFileChannelDeprecationNotice the sync cycle ✅ M1–M4 red
Operator Communication prompt test_1402_prompt_contract.py every composed turn 🛡 guard (the text is the product)
MCP askOperator, askRefusal, raiseAsk operator_queue.test.ts (stub), access-wiring.test.ts (real transport) server registration ✅ MT1–MT3 red
queueReaskBadges, QueueCard, ResolvedCard operatorQueueReask.spec.js (mounted) the Operations cards ✅ call-site mutations red

Fix mutations: not a fix PR. The three fixes this review made each went red first; see below.

Fixed in this review (each with a failing test first)

  • F1 — Audit: an agent-raised ask was recorded as the owner acting in person.
    • The cause: the raised row passed the key's resolved owner as actor_user, and _resolve_actor ranks a user above an agent.
    • The fix follows the ent#614 rule (routers/fan_out.py): no actor_user, and the key and the owner's email carried explicitly.
    • The route test asserts the real resolver's answer: ("agent", <agent>, None).
  • F2 — Tests: the race tests could not lose the race.
    • With the PostgreSQL advisory lock removed, they passed 3 runs out of 3, because the threads serialized on pool connection setup.
    • Each create is now held inside its critical section.
    • A new requires_postgres module, run by CI's schema-parity against PostgreSQL, pins the cap, concurrent retries, per-agent isolation and the outcome accessor. Removing either backend's lock turns it red on every run.
  • F3 — Tests: a replay's differs with a deadline. The behaviour was correct; it is now pinned for Z, +02:00 and -05:30, and a mutation proves the test bites.

Informational (accepted, not fixed)

  • I1 — The native per-agent lock does not serialize with the file poller's inserts. During the deprecation window, a file ingest and a native raise landing together can exceed the depth cap by one. The poller is single-leader, and this goes away with the file.
  • I2 — The Workspace thread attach runs before the atomic create, so an owner's Main chat may be created for an ask that is then refused queue_full. That chat is an ordinary one.
  • I3 — A rate token is spent when the create then answers queue_full, or replays after a race. The file path accepts the same drift.
  • I4 — Two re-asks can name the same expired ask; the predecessor's card shows only the first successor.

Clean categories

  • SQL: Core statements with bound parameters. The advisory-lock key is an integer from sha256.
  • Auth:
    • The self-acting gate: 403 agent_identity_required for a sibling agent, a user key, or a system key for another agent.
    • Ghost keys are refused by the ephemeral allowlist.
    • test_2094 path-param pairing is green.
  • Credentials: no email in receipts or broadcasts. The owner's email appears only in the operator-facing audit row.
  • Enterprise disclosure: 0 hits, with a positive control.
  • Frontend: no v-html; agent text reaches the badges only through bound attributes.
  • Enum completeness:
    • delivery_detail='mcp_raised' is written but nothing switches on it.
    • Audit actions are an open set.
    • channel='mcp' is filtered everywhere through _file_contract().
  • Docs: requirements §26.10, the operating-room flow, the architecture catalogues and the user docs are updated.

Verification

  • Full unit island at 03b39917d: 40 failed / 18,374 passed. The failures are exactly the 40 order-dependent ones from the clean baseline; none is new.
  • Neighbour suites plus the guard suites: 496 passed.
  • MCP: 556 passed, and tsc is clean.
  • Frontend: 3,654 passed. Two files fail to load on this machine only, because @vue/compiler-sfc is missing from the host install; this predates the PR.
  • Alembic: no migration in this PR.

After the review: /cso --diff

No open findings (docs/security-reports/cso-diff-2026-09-25-ent611-native-ask.md). It prompted two changes, each with a failing test first:

  • Addressee reads. A native row's addressed_to_email (the person the platform resolved) is now withheld from machine keys, like resolved_to. It was verified live and then refuted as a new disclosure, because /api/users/me already returns the owner's email to the same key. The change is kept because it closes the people_for path.
  • Published schema. The live tools/list said context / proposal accept no keys; both are now object-or-null, pinned over listTools.

The eyeball gate passed on a local instance.

Summary

  • Critical: 0 (three found and fixed during the review, listed above).
  • Informational: 4, all accepted.
  • Scope: clean.

@vybe

vybe commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-09-27): not on this run. #3023 and #3026 are on dev; this one rides once the two items below are settled. Nothing was pushed to this branch. Validated at lane B, head 3a2dffdf1, own delta over #3023 only.

1. An operator approving a native ask never sees proposal. The new prompt (platform_prompt_service.py:156, mirrored in the meta-prompt and the agent guide) tells agents to state the exact action and its parameters in proposal "so the operator can verify what they are approving". It used to say context. Nothing under src/frontend/src reads proposal: QueueCard.vue:90-101 and QueueItemDetail.vue:76-80 render context only, and the mobile and Workspace cards are the same. So an agent that follows the prompt moves the one thing the approver has to check out of view. Its only readers today are the re-ask guard, differs and the agent's own readback. Either render proposal on every surface that offers an approval, or keep the prompt pointing at context until it is rendered.

2. OPERATOR_QUEUE_PROPOSAL_MAX_BYTES is read but not wired. operator_queue_service.py:89 reads it; it is missing from docker-compose.yml, docker-compose.prod.yml and .env.example, where all 15 sibling OPERATOR_QUEUE_* vars are present. The 8192 default works, the lever does not.

Smaller items:

  • ask_service.py:326 runs the re-ask scan (up to 200 proposals of 8 KB) and the owner lookup before the rate check at :329, and a 422 spends no token.
  • The receipt's supersedes_expired is a request_id; get_my_ask (routers/operator_queue.py:474) returns the predecessor's uuid for the same field.
  • ask_service.py:578 _owner_email is stubbed in every test, so the default primary to owner path is never executed.
  • people_for / resolve_role_people have no implementer, so approver and viewer always refuse.
  • No test crosses MCP into the real backend, and route tests override get_current_user, so real key resolution and the ephemeral fence are not exercised on this route.

What holds: the agent-callable write is fenced by get_self_acting_agent with a uniform 403 then 404; to is a role, never a person; no email reaches a receipt, readback or machine-key read; (agent, request_id) is deduplicated atomically; every field is bounded; the broadcast carries ids only. 848 tests across the 31 touched and neighbouring unit files pass on this head.

Retarget: backend-unit-test, both CodeQL analyses and journey-smoke have never run here because the base is not dev. Retarget to dev first, then merge dev in. #3026 touches the same return block in db/operator_queue.py (get_sync_index_for_agent), so expect a conflict there. Fixes on ent#611 will close it with the gate-initiated criterion still deferred to #164.

…ipts, re-asks (Abilityai/trinity-enterprise#611, PR B)

An agent raises an ask through ONE platform call instead of appending to
~/.trinity/operator-queue.json: POST /api/agents/{name}/operator-queue and
the MCP tool ask_operator, both self-acting (the agent comes from the key;
any other principal gets 403 agent_identity_required).

- ask_service.raise_ask is the create entry point of the ask sink, the seam
  the gate path (trinity-enterprise#164) calls with raised_by="gate". Static
  validation with named 422s; replay BEFORE every time-dependent check; the
  15-minute deadline floor; the own-expired-predecessor link and the
  reask_requires_link refusal; role addressing; the #1632 rate buckets; an
  atomic per-agent create (replay, depth cap and insert under one lock: a PG
  advisory transaction lock, SQLite BEGIN IMMEDIATE); one `raised` audit row
  and a thin operator_queue_new trigger.
- The receipt names the role, never an email. A replay returns the first
  receipt with `differs`; `wakes_on_ending` says whether the owner's wake
  opt-in is on.
- Native rows sit outside the file contract everywhere. The poller skips a
  file entry that re-uses a native id before any branch reads it (logged
  once), creates through create_operator_queue_item_with_outcome and counts
  only what it inserted, and names the file channel's deprecation once per
  agent per process.
- The Operator Communication prompt leads with ask_operator by its bare
  name; the file is the fallback for two releases (the meta-prompt copy and
  the agent guide mirror it).
- The Operations cards link a re-ask and the expired ask it re-raises.

Rebuilt on dev after #3023 squash-merged (f6dfb00): this commit is the net
delta of the former PR B commits 19b2e53..3a2dffd (the feat above, the
two review fixes: the `raised` audit row is the agent's, and reads never
name a native ask's resolved person; plus the docs), replayed onto dev
unchanged. Same 47 files and the same added/removed lines as B's old diff
against #3023's final head dfcb7c0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@webmixgamer
webmixgamer force-pushed the feature/ent611-native-ask branch from 3a2dffd to 8312311 Compare September 27, 2026 10:57
@webmixgamer
webmixgamer changed the base branch from feature/ent611-ask-object to dev September 27, 2026 10:57
@webmixgamer webmixgamer reopened this Sep 27, 2026
@webmixgamer

Copy link
Copy Markdown
Contributor Author

Closed and reopened only to start CI on the dev base. The rebuilt branch (831231102, see Merge notes) was pushed while the base was still #3023's branch, so the dev-only workflows (backend-unit-test, journey-smoke) never ran on it. No code changed.

…Abilityai/trinity-enterprise#611, PR B)

The native ask path reads OPERATOR_QUEUE_PROPOSAL_MAX_BYTES (default 8192),
but no compose file passed it into the backend container, so setting it in
.env had no effect on a deployed instance. The 14 sibling operator-queue caps
are all in the three compose files and .env.example; this one now is too.

It sits right after OPTIONS_MAX_BYTES, the same position as the constant in
operator_queue_service.py. So prod and hosted keep identical environment
lists, and the #2280 parity guard compares them in order. The deploy guide's
env table and requirement §26.10 name it as well.

Found by /validate-pr §4.9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@webmixgamer
webmixgamer requested review from dolho and vybe September 27, 2026 14:04

@AndriiPasternak31 AndriiPasternak31 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes. The plumbing is good: the per-agent atomic create, the rowcount-based inserted, the file-contract exclusion and the named refusals. Suites are green locally (245). Three contract items, two of them about the #164 seam I'll build on:

  1. proposal is shown to nobody. The prompt (platform_prompt_service.py:156, the meta-prompt, the agent guide) now tells agents to put the exact action in proposal instead of context. context renders on QueueCard (:90); proposal renders nowhere (Ops cards, Workspace). On merge, approvers approve without seeing the amount or target. Please render it read-only on the cards, or keep "mirror it into context" until you do.
  2. The replay namespace is shared across raised_by. raise_ask replays any (agent, request_id) row. I checked: an agent-raised gate-call-abc comes back as replayed to a later raised_by="gate" call with a different proposal, and the receipt has no raised_by. The gate also can't use a reserved prefix, because _validated_ask refuses reserved_request_id regardless of caller. That is the #1632 pre-squat class aimed at the approval gate. Suggested fix:
    • reserved-prefix refusal for raised_by == "agent" only, plus one prefix reserved for platform raises;
    • refuse a replay whose stored raised_by differs;
    • put raised_by on the receipt.
  3. people_for → [] for primary goes to the owner. resolve_role_people documents [] as "nobody fills it", but _address does if people: and falls through to the owner, with resolved: true. ent#606 says primary falls back to operator when no primary is assigned; owner = primary only without a provider. Please branch on is not None and add the [] test.

Smaller: create_native_item's INSERT has no conflict clause. On PG, a file ingest of the same id racing the native raise gives a 500 instead of replayed, because the poller doesn't take the advisory lock. One on_conflict_do_nothing + re-read fixes it.

On the #164 seam (answering your ent#164 question): one raise, one end sink, observers that only see CAS winners — agreed. For the gate to need no #611 edits:

  • raise_ask should take the caller's limits, not the agent's:

    • the 8 KB proposal cap refuses a frozen SKILL.md, and skills are in the v1 class;
    • C6 reask_requires_link fires on a gate re-raise after a timeout, and the agent can't supply "what's new" through a tool call;
    • the agent's rate/depth bucket applies to the gate too.

    I'd scope C6 and the agent caps to raised_by == "agent".

  • _wake_filer should only wake raised_by == "agent" rows. Otherwise, on a gate approval, the agent is woken to redo the action while the gate resumes it.

  • Observers are in-process at-most-once; fine, #164 will keep its own durable effect_guard reconciliation keyed on ask id. Worth one line in the seam docstring.

  • Later, additive: an end-side "may this person end this ask" predicate against to_role/resolved_to. Today any person with access to the agent can answer an approval addressed to approver.

Happy to pair on the F2 shape so #164 can start against it.

@AndriiPasternak31

Copy link
Copy Markdown
Contributor

Correction to my review's seam note: C6 does not block the gate. After a timeout, a gate re-raise that sets supersedes_expired to the refusal's expired_request_id succeeds, and the card shows it as a re-ask (probed on a4e77c974). Please keep expired_request_id in that refusal as part of the contract; I'll rely on it.

The full seam reply is on trinity-enterprise#164. What still needs to land in #611 before merge:

  • one platform-reserved id prefix, refused only for raised_by == "agent", which fixes both the pre-squat and the default wake on gate rows;
  • rendering proposal on the cards;
  • people_for → [] for primary.

…ed prefix, raised_by on the receipt, primary's empty answer, rate check first (Abilityai/trinity-enterprise#611, PR B)

From the changes-requested review and the merge-train note on #3028:

- `gate-` is a platform-reserved id prefix. A gate raise (`raised_by="gate"`,
  `channel="gate"`) must use it, and an agent's raise may not. So an agent
  can no longer pre-create the gate's id and have the gate's raise answered
  as a replay of the agent's own proposal. And it is no longer woken to redo
  an action the gate resumes itself, because `is_platform_minted` keys on the
  reserved tuple and gates both wakes. The receipt now says who raised the
  ask (`raised_by`), and an unknown raiser or channel is a ValueError.
- A provider that answers "nobody" (`[]`) for `primary` now sends the ask to
  the operators with `resolved: false` (the ent#606 fallback). The owner
  stands in only when no provider answers at all.
- The rate check runs right after the replay, before the re-ask scan and the
  owner lookup read the database. A refused ask now spends a token; a replay
  still spends none.
- The native insert takes ON CONFLICT DO NOTHING. The file poller does not
  take the per-agent lock, so on PostgreSQL a file entry re-using the id can
  land between the replay check and the insert. That now comes back as a
  replay of the file row, not a 500.
- `get_my_ask` names a re-ask's predecessor by request_id, as the receipt
  does. The row still stores its uuid.
- Docstrings: a gate raise shares the agent's rate and depth budget (it fails
  closed), and ending observers run in-process and at most once.
  `channel` is documented as file|mcp|gate.

Tests came first: 18 went red before their fixes. New tests also run the
real owner lookup, and a real minted key through the route: key resolution,
the identity check and the ephemeral fence. Each of 13 mutations of the
fixed lines turns a test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ision is made (Abilityai/trinity-enterprise#611, PR B)

The prompt tells agents to put the exact action in `proposal` "so the
operator can verify what they are approving", but nothing rendered it. An
agent that followed the prompt moved the one thing an approver must check
out of view (review on #3028).

- `components/operator/QueueProposal.vue` renders it read-only. The rows
  come from `utils/operatorQueue.js::proposalRows`: top-level fields in the
  agent's order, text verbatim, anything else as compact JSON. It is
  agent-authored, so the values are text only (never v-html). The component
  uses gray tokens only, and the list scrolls inside its own box.
- It appears on every card that offers the decision: the open Operations
  card (above the collapsed context, never behind "Show details"), the /m
  ops card and the Workspace ask.
- The Workspace projection (`WorkspaceAsk`) now includes `proposal`;
  `context` stays out. An ended ask keeps its proposal, so the person sees
  what was decided.
- `QueueItemDetail.vue` has no importer and reads store state that no
  longer exists, so it is left alone.

Mounted specs cover the three surfaces, the rule and the text-only
rendering (12 tests), with two projection tests beside them. Each of 7
mutations of the wiring turns a test red. Checked by eye on a local
instance in light and dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@webmixgamer

Copy link
Copy Markdown
Contributor Author

@AndriiPasternak31 @vybe, thanks both. Everything below is on the branch (head a13880487).

Before merge (Andrii's list)

  1. Platform prefix (d929c00f8): gate- is in _RESERVED_ID_PREFIXES. An agent's raise may not use it (422 reserved_request_id). A gate raise must use it (422 invalid_request_id otherwise). That second rule goes one step past your suggestion: a gate raise with an unprefixed id could be pre-created again, and its row would wake the agent. Your gate-<hash> ids already satisfy it. The receipt now carries raised_by. Your P2, P3 and P7 are tests now. As you wanted, the "recently ended" prompt line drops gate rows through the same predicate.
  2. proposal rendered (a13880487): one read-only block, QueueProposal.vue, on every card that offers the decision. That is the open Operations card (above the collapsed context), the /m ops card and the Workspace ask. WorkspaceAsk now names proposal; context still stays off. Values render as text, never markup. QueueItemDetail.vue has no importer, so it is untouched.
  3. people_for → [] for primary (d929c00f8): it now branches on is not None. An empty answer sends a primary ask to the operators with resolved: false. Your P1 is a test now.

Smaller

  • PG race: the native insert now has ON CONFLICT DO NOTHING and re-reads the row. A file entry that lands between the replay check and the insert now returns replayed instead of a 500. The test runs on both backends.
  • Docstrings: raise_ask says a gate raise shares the agent's rate and depth budget and fails closed (your P8). register_ending_observer says observers are in-process and at most once.
  • expired_request_id stays in the reask_requires_link refusal, pinned by a backend test and an MCP test.

vybe's merge-train note

  • OPERATOR_QUEUE_PROPOSAL_MAX_BYTES is wired into all three compose files and .env.example (a4e77c974).
  • The rate check now runs right after the replay, before the re-ask scan and the owner lookup read the database. A refused ask spends a token; a replay still spends none.
  • get_my_ask names the predecessor by request_id, like the receipt.
  • New tests run the real owner lookup, and a real minted key through the route with no override:
    • its own key → 201;
    • another agent's key → 403 agent_identity_required;
    • an ephemeral agent's own key → 403 from the fence.
  • approver / viewer still refuse until an assignments provider registers people_for.
  • The retarget is done. feat(operator-queue): ask endings — one sink, endings ledger, person-only endings, wake on any ending, self-readback (abilityai/trinity-enterprise#611, PR A of 2) #3023's squash made a dev merge conflict in about 20 files, so the branch was rebuilt on dev instead (see the merge notes in the body).

Andrii's questions (ent#164)

  • channel="gate": yes. The sink accepts mcp and gate (anything else is a ValueError), and the column is documented file|mcp|gate.
  • Interactive-only gate decisions: not decided in this PR. The gate's PR can settle it with vybe.

Evidence:

  • 18 tests went red before their fixes, and each of 20 mutations of the new lines turns a test red.
  • Full unit island on the branch merged with dev: 0 failed. Frontend (vitest): 3,682 passed. MCP server: 567 passed.
  • Checked by eye on a local instance, in light and dark.

@AndriiPasternak31 AndriiPasternak31 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. Thanks for the fast turn. I checked each item at a13880487:

  • Prefix: fixed. An agent gate-x → 422 reserved_request_id, and the agent can't reach the replay branch afterwards. A gate raise without the prefix → invalid_request_id, which is stricter than I asked and correct. The file poller now refuses gate-/GATE- entries too, and gate rows stay off both wakes and the prompt line. raised_by is on the receipt.
  • proposal: rendered on the open Ops card, /m and the Workspace ask, as text, bounded. The mounted specs and the ratchets are green.
  • [] for primary: now goes to Ops with resolved: false, and approver [] → role_unassigned.
  • PG race: ON CONFLICT → replayed, green on postgres in schema-parity.

One test fix before merge; see my separate comment.

Cheap now; otherwise I'll take them in #164's seam PR:

  • _receipt (ask_service.py:710) reports wakes_on_ending: true for a gate row when the agent opted in, but _wake_filer skips it. Add and not is_platform_minted(row).
  • C6 crosses raisers. An agent's expired ask with the gate's exact proposal makes the gate's raise 422 reask_requires_link pointing at the agent's row, and linking it badges the gate approval "Re-ask of" the agent's ask; the reverse link works too. Filtering list_expired_proposals_for_agent and _predecessor on the caller's raised_by closes both.

Nits:

  • A legacy file row named gate-… still replays to the gate (raised_by: agent). The receipt makes it detectable, and the gate will assert on it, but a 409 on a raised_by mismatch at :340 would make it structural.
  • Pin channel == "gate" iff raised_by == "gate".
  • ResolvedCard doesn't show proposal, while the Workspace keeps it on ended asks.

On #164: the four seam hooks (end policy, Actor.interactive, resolve_addressees, per-caller cap) all fit this shape without touching anything you changed. I'll re-stack on this head.

@AndriiPasternak31

Copy link
Copy Markdown
Contributor

@webmixgamer one fix before merge: test_an_agent_may_not_author_a_gate_id is order-dependent (tests/unit/test_ent611_native_ask.py:670).

  • Its sibling test_the_gate_raises_under_its_own_prefix_and_the_receipt_says_so (:673) creates gate-call-abc on the same agent (agent-611b-gate), and the fixture keeps rows between tests. When the sibling runs first, the negative test's ... is None assertion fails.
  • Reproduced at a13880487: run on its own, the file fails on pytest-randomly seeds 67890 and 99999, two of the three NIGHTLY_SEEDS, and passes on 12345, the PR seed. That is why PR CI is green; the dev nightly would go red.
  • Fix: give the negative test its own id (e.g. gate-agent-squat) or its own AGENT.

webmixgamer and others added 2 commits September 28, 2026 13:16
…est, gate rows' receipts, replay and re-asks stay with their raiser, proposal on Resolved (Abilityai/trinity-enterprise#611, PR B)

From the approving review on #3028:

- `test_an_agent_may_not_author_a_gate_id` now has its own agent and ids.
  A sibling test creates `gate-call-abc` on the same agent, and the unit
  database keeps rows between tests. The test failed on 2 of the 3 nightly
  seeds and passed on the one seed PR CI uses. It now passes under 20 seeds,
  the nightly three included.
- A gate row's receipt no longer promises a wake: `wakes_on_ending` is false
  on a platform-minted row, which `_wake_filer` skips.
- `channel="gate"` if and only if `raised_by="gate"`; anything else is a
  ValueError.
- A gate raise is never answered as a replay of a row it did not raise, at
  either replay site (409 `request_id_taken`). Example: a file row named
  `gate-…` written before the prefix was reserved.
- The re-ask guard (C6) and the re-ask link compare with the SAME raiser's
  expired asks. An agent's expired ask no longer blocks, or gets linked by,
  a gate raise, and the reverse. `list_expired_proposals_for_agent` gains a
  `raised_by` filter. `_raiser_of` treats a row older than the column as the
  agent's own, and a platform alarm as nobody's.
- The Operations Resolved card keeps the proposal too, so the record says
  what was decided (the Workspace already did).

Tests came first: 9 went red before their fixes, and 2 more pin the legacy
rows. Each of 12 mutations of the fixed lines, both replay call sites
included, turns a test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s globals (Abilityai/trinity-enterprise#611, PR B)

The full unit island at nightly seed 67890 failed the staged-race test on
PostgreSQL's sqlite leg with `created` where `replayed` was expected.

The holds were installed with `monkeypatch.setattr(dbq, "make_insert", …)`
after `import db.operator_queue as dbq`. In a full-suite run a sibling test
can leave a stand-in for `db.operator_queue` that re-exports the real
class. The patch lands on the stand-in, while the real method reads its own
module dict, so the hold never fires. Reproduced with a plugin that installs
such a stand-in:
- the committed staged-race tests fail;
- the concurrent-cap test passes even with the lock removed.

All four holds now go through `create_native_item.__globals__` (test_1632's
`_patch_engine`), and the table object comes from the same dict. With the
stand-in installed, the fixed tests pass, and removing the lock turns the
cap test red again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@webmixgamer

webmixgamer commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor Author

@AndriiPasternak31, thanks for the approval and for the seed repro. All of it is on the branch now (head df1ea4cd1).

The test fix

  • test_an_agent_may_not_author_a_gate_id now has its own agent and ids. The file passes under 20 seeds, the three nightly ones included. Before the fix, it failed on 10 of 15.
  • Running the full island at seed 67890 then showed a second test of the same family. On PostgreSQL's sqlite leg, the staged-race test returned created.
    • Its hold was patched through import db.operator_queue as dbq. In a full run, a sibling test can leave a stand-in for that module that re-exports the real class, so the hold never fired. This is test_1632's module-identity gotcha.
    • All four holds now patch create_native_item.__globals__ (df1ea4cd1).
    • I reproduced it with a plugin that installs such a stand-in. The old staged-race tests fail, and the old cap test passed even with the lock removed. The fixed tests pass, and removing the lock turns the cap test red again.

The items you'd have taken into #164 (06737d749)

  • wakes_on_ending is false on a platform-minted row.
  • C6 and the re-ask link only compare an ask with its own raiser's expired asks:
    • list_expired_proposals_for_agent takes raised_by, and _predecessor checks it;
    • a row older than the column counts as the agent's, and a platform alarm counts as nobody's. Both cases are pinned.
  • A gate raise that would replay a row the gate didn't raise now gets 409 request_id_taken, at both replay sites.
  • channel == "gate" if and only if raised_by == "gate", with a ValueError otherwise.
  • The Resolved card shows proposal too, so the record says what was decided.

Each of 12 mutations of those lines turns a test red, both replay call sites included. Full unit island on the branch merged with dev: 0 failed at nightly seed 67890 (18,627 passed) and 0 failed at 99999 (18,628 passed). Frontend: 3,684 passed.

@vybe

vybe commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

merge-train: changed Fixes abilityai/trinity-enterprise#611 → Refs in the body. This PR delivers the agent-initiated and idempotency criteria, but the gate-initiated criterion is only a seam here (ent#164 still open), so a closing keyword would close #611 at the release cut before it is delivered. status-in-dev will be set on ent#611 by hand after merge.

Non-blocking, for a follow-up: services/operator_resume_service.py:89 wakes the filer with Queue item: {item['id']} (the platform uuid), while get_my_ask looks asks up by request_id and the expired/cancel wake at :245 already uses request_id. Pre-existing, but for a native ask that wake is the only delivery channel.

vybe pushed a commit that referenced this pull request Sep 29, 2026

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: batch validated on train/20260929-0815 (#3070, all checks green).

@vybe
vybe merged commit a7de3b2 into dev Sep 29, 2026
31 checks passed
dolho added a commit that referenced this pull request Sep 29, 2026
…n — reports and messages (Abilityai/trinity-enterprise#606)

The ent#606 ruling (prds/permission-layers.md §5a): every outbound object
an agent produces for a human carries `to:` — primary | approver | viewer
| operator — and the platform resolves the person through the agent's
assignments. #611 (#3028) built it for asks; this extends it to reports
and messages through ONE shared rule.

- services/role_addressing.resolve: a provider's people_for answer first,
  else the core defaults (primary → owner, operator → nobody,
  approver/viewer refused). No primary assigned → operator. Refusals are
  named (invalid_to, role_unassigned). ask_service._address now delegates
  to it; asks are unchanged (#3028's tests pass as they were).
- Reports: ReportCreate.to.
  - operator, nobody, or the owner → operator-only (the owner reads the
    operator surface).
  - one other person → the checked audience; off the roster → 422
    role_unreachable.
  - several people → 422 role_resolves_to_several.
  - to + audience_email → 422 addressing_conflict.
  - Neither → operator-only, unchanged.
- Messages: exactly one of `to` (primary | approver | viewer) or
  recipient_email. A role nobody fills or several fill is a named 422;
  operator is not a message recipient (use an ask_operator alert).
- MCP report / send_message take `to` and echo the role, never the
  resolved person.
- Agent-supplied emails still work for two releases, logged as
  deprecated.

The provider side (people_for over role assignments) ships separately in
the private module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AndriiPasternak31 added a commit that referenced this pull request Sep 29, 2026
Brings in 58 commits from dev. Eight conflicts resolved:

- client_portal/asks/{service,router}.py — #3059's paged read (limit/cursor,
  X-Total-Count, SQL-side roster set) combined with A0's fail-loud contract:
  the roster check stays strict, and an unreadable queue or roster raises
  AsksUnavailable -> 503 asks_unavailable instead of dev's empty page (which
  the client would render as "nothing is waiting"). Mutation-checked: dev's
  `return AsksPage()` turns 4 A0 tests red.
- PortalAsks.vue — the markdown body (sign-off) plus #3028's QueueProposal.
- PortalConversation.vue — useStickToBottom's `enabled` option (7c1c6dc)
  plus the Inbox anchor's `detach`.
- Portal.vue — #3060's railSizedOpen, with the Inbox placeholder rail counted
  as visible.
- api-endpoints.md / operating-room.md / tests/registry.json — both sides kept;
  the asks row now states the 503.

Test updates the merge requires:
- test_ent610_asks_unavailable.py faults all three queue reads #3059 split
  the read into (the first runs before any early return).
- test_3064_portal_unread_index.py builds agent_reports too: ent#610 D3's
  unread count reads it, so the fresh track could not run the real query.
  All 4 pass, including its negative control.

Verified: backend unit 19074 passed / 42 failed — 40 are the known
order-dependent set (ent477 x2, ent666, retention_floor; files identical to
dev, 119/119 in isolation) and the 2 test_3064 are fixed above; frontend 4090
passed + build; alembic single head; live stack: every sign-off behaviour
re-probed, a reply's quote confirmed in the agent's prompt.
vybe added a commit that referenced this pull request Sep 30, 2026
…n — reports and messages (Abilityai/trinity-enterprise#606) (#3050)

* feat(addressing): agents name a role, the platform resolves the person — reports and messages (Abilityai/trinity-enterprise#606)

The ent#606 ruling (prds/permission-layers.md §5a): every outbound object
an agent produces for a human carries `to:` — primary | approver | viewer
| operator — and the platform resolves the person through the agent's
assignments. #611 (#3028) built it for asks; this extends it to reports
and messages through ONE shared rule.

- services/role_addressing.resolve: a provider's people_for answer first,
  else the core defaults (primary → owner, operator → nobody,
  approver/viewer refused). No primary assigned → operator. Refusals are
  named (invalid_to, role_unassigned). ask_service._address now delegates
  to it; asks are unchanged (#3028's tests pass as they were).
- Reports: ReportCreate.to.
  - operator, nobody, or the owner → operator-only (the owner reads the
    operator surface).
  - one other person → the checked audience; off the roster → 422
    role_unreachable.
  - several people → 422 role_resolves_to_several.
  - to + audience_email → 422 addressing_conflict.
  - Neither → operator-only, unchanged.
- Messages: exactly one of `to` (primary | approver | viewer) or
  recipient_email. A role nobody fills or several fill is a named 422;
  operator is not a message recipient (use an ask_operator alert).
- MCP report / send_message take `to` and echo the role, never the
  resolved person.
- Agent-supplied emails still work for two releases, logged as
  deprecated.

The provider side (people_for over role assignments) ships separately in
the private module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(addressing): role-only delivery errors, symmetric deprecation log (Abilityai/trinity-enterprise#606)

Review follow-ups on PR #3050:
- A role-addressed message that fails authorization (403) or delivery (404)
  now returns a role-only detail (role_not_opted_in / role_no_channel); the
  service's text names the resolved email, so it passes through only for
  email-addressed sends.
- The message-side email-addressing deprecation log fires only for agent
  keys, matching reports.
- Drop the unreachable invalid_to mapping in ask_service._address; its only
  caller passes a role already validated against ASK_ROLES.
- Cite trinity-enterprise#606 only, not a private design file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(reports): withhold the resolved person from a role-addressed agent's create response (ent#606)

merge-train mechanical fix: POST /api/agents/{name}/reports returned
addressed_to (the resolved person's email) to an agent-key caller. Route it
through _hide_audience like the read routes, and assert the response body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants