LearnNewsExamplesServices
Frontmatter
id15106
titlePersist the winning assignee for broadcast A2A Tasks
stateClosed
labels
bugaitestingarchitecturesecurity
assigneesneo-gpt-emmy
createdAtJul 12, 2026, 10:39 PM
updatedAt12:41 AM
githubUrlhttps://github.com/neomjs/neo/issues/15106
authorneo-gpt-emmy
commentsCount1
parentIssue15100
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[ ] 15114 Give Task transitions durable GraphLog event identity
closedAt12:41 AM
milestonev13.2

Persist the winning assignee for broadcast A2A Tasks

Closed Backlog/active-chunk-5 bugaitestingarchitecturesecurity
neo-gpt-emmy
neo-gpt-emmy commented on Jul 12, 2026, 10:39 PM

Context

ADR 0035 makes a claimed A2A Task one of the exact source facts behind the v13.2 LifecycleFrontier. A source audit before filing that producer found a lower-level authority defect in the current Memory Core state machine: a broadcast Task's Submitted → Working transition serializes the first state change, but it does not persist who won.

This ticket repairs that source contract before any lifecycle composer, hook projection, Fleet reader, or UI consumes it. The ordering is deliberate: building those consumers over an ownerless Working Task would turn an existing ambiguity into a trusted false fact.

The Agent OS structure map was run against current dev. The owner is the existing ai/services/memory-core/MailboxService.mjs task-transition boundary and its existing wake consumer; no new .mjs file or service directory is introduced.

The Problem

MailboxService.transitionTask() currently derives isAssignee from the MESSAGE's SENT_TO edge on every transition. For SENT_TO: AGENT:*, every authenticated recipient therefore remains an assignee after one peer has already won Submitted → Working.

The optimistic UPDATE ... WHERE task.state = 'Submitted' protects only the first state mutation. It does not bind later authority:

  1. Alice sends a broadcast Task in Submitted.
  2. Bob wins Submitted → Working.
  3. The stored Task contains state: 'Working' but no winning assignee.
  4. Charlie still satisfies the broadcast isAssignee check and can execute Working → InputRequired|Completed|Failed.

That violates the original claim-and-lock intent from Discussion #10313 / ticket #10338: the first claimant wins the Task, not merely the first write.

Two adjacent facts confirm the missing owner:

  • addMessage() treats the Task payload as caller-authored opaque JSON apart from state validation, so a caller-supplied task.assignee is not trustworthy.
  • heartbeatPulseEvaluator already reads task.assignee, but no Memory Core write path owns or updates that field. It also reports properties.updatedAt || sentAt even though transitions persist properties.lastModifiedAt.

The negative ROI is severe: without this repair, stage-4 awareness can attribute one Task to multiple peers, wake the wrong actor, and permit a non-winning peer to terminally mutate another peer's claimed work.

The Architectural Reality

  • MESSAGE routing edges own delivery audience. SENT_TO: AGENT:* means “claimable by the broadcast cohort”; it cannot remain the durable post-claim owner.
  • MailboxService already owns Task state validation, transition RBAC, request-bound identity, and the atomic SQLite state update. Claim ownership belongs in that same transaction.
  • The Task envelope is already mutated by the transition API. task.assignee is the existing downstream consumer field, so this repair makes it a server-owned Neo extension rather than adding a second graph edge or parallel owner record.
  • RequestContextService.getAgentIdentityNodeId() is the canonical claimant identity. Caller payload, session id, subject/body text, and Fleet process identity are not assignment authority.
  • properties.lastModifiedAt is the existing transition clock required by the graduated A2A contract. Wake and later lifecycle reads must consume it, not synthesize a timestamp.
  • This is Memory Core source ownership only. The zero-authority lifecycle normalizer later maps source facts into fixed stages; it does not repair Task identity.

The Fix

1. Make task.assignee server-owned

When addMessage() accepts a Task:

  • for a direct AgentIdentity recipient, clone the caller Task and overwrite task.assignee with the canonical validated recipient;
  • for AGENT:*, overwrite task.assignee with null until a claimant wins;
  • never trust a caller-supplied assignee;
  • stamp the MESSAGE with top-level taskAssignmentAuthority: 'memory-core.v1', outside caller-controlled Task JSON, so later readers can distinguish a Memory-Core-owned assignment from legacy spoofable payload;
  • leave non-Task messages unchanged.

Document assignee and its provenance marker as read-only/server-owned Neo Task extensions on the Memory Core MCP surface and in the A2A guide.

2. Bind the broadcast winner atomically

For Submitted → Working:

  • direct Task: preserve/backfill the canonical direct recipient as assignee;
  • broadcast Task: write task.state = 'Working', task.assignee = <bound claimant>, taskAssignmentAuthority = 'memory-core.v1', and properties.lastModifiedAt = <same timestamp> in the same guarded SQLite update;
  • the race loser refreshes both state and assignee from SQLite and cannot overwrite the winner.

3. Enforce later RBAC against the winner

For assignee-owned transitions out of Working, compare the bound identity with the persisted canonical task.assignee, not the broadcast audience edge.

Backward compatibility is fail-closed:

  • a legacy direct Task without task.assignee may derive/backfill only from its one direct SENT_TO AgentIdentity;
  • a legacy broadcast Task already beyond Submitted (including Working or InputRequired) without a trusted provenance marker plus canonical task.assignee has no provable winner and must fail closed with an explicit owner-unknown error;
  • originator-owned transitions retain the existing RBAC matrix when canonical ownership is known; Submitted → Canceled remains valid for an unclaimed broadcast because it cannot recreate ownerless active work.

4. Truth-sync the wake consumer

TASK_STATE_CHANGED matches the originator or an assignee carrying the exact server-owned taskAssignmentAuthority marker and reports properties.lastModifiedAt as the transition clock. It must not accept a caller-spoofed assignee or fall back to a stale send timestamp for a real transition.

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback / Edge Case Docs Evidence
Existing MailboxService.addMessage({to, task}) ADR 0035 §2.3/§2.4 + validated MESSAGE routing Preserve caller Task content but overwrite server-owned task.assignee: direct canonical recipient or null for broadcast; stamp top-level taskAssignmentAuthority Non-Task unchanged; caller assignee never grants authority; non-AgentIdentity Task recipient remains non-transitionable JSDoc + OpenAPI + A2A guide Direct, broadcast, caller-spoof, and round-trip unit specs
Existing MailboxService.transitionTask() Submitted → Working Discussion #10313 OQ5/OQ6 + ADR 0035 stage 4 Atomically persist state, canonical winning assignee, and one lastModifiedAt timestamp under the existing expected-state guard Race loser returns fresh state+assignee; missing identity/invalid routing fails closed JSDoc Two-claimant race plus SQLite/cache equality specs
Existing assignee-owned `Working → InputRequired Completed Failed` Persisted Task assignee plus exact Memory Core provenance marker Only the direct/winning assignee may transition Legacy direct can backfill from one canonical target; any ownerless legacy broadcast beyond Submitted rejects as owner-unknown
Existing TASK_STATE_CHANGED wake evaluation Graph-owned MESSAGE Task + server-owned assignee + lastModifiedAt Notify originator or canonical assignee with the actual transition clock Missing/untrusted assignee cannot widen recipients; malformed Task returns no match JSDoc Evaluator + subscription tests for recipient and timestamp
Existing Task read surfaces Memory Core MESSAGE Task projection get_message / list_messages expose the canonical task.assignee after write/transition Legacy absent stays visibly absent until safe direct backfill; never infer broadcast winner OpenAPI Read-after-write and read-after-claim specs

Decision Record Impact

Aligned with ADR 0035. This localized source-authority repair implements the Task owner fact required by §2.3 and the categorical identity rule in §2.4. It does not amend the four-surface composition, add graph ontology, or change Fleet capabilities.

Decision Record: Not needed — the cross-substrate decision already graduated through Discussion #15090 and ADR 0035; this ticket repairs the existing Memory Core primitive beneath it.

Acceptance Criteria

  • Direct Task creation stores the canonical validated direct recipient in server-owned task.assignee, overriding any caller-supplied value.
  • Broadcast Task creation stores task.assignee: null; caller payload cannot pre-claim it.
  • Task creation and safe legacy backfill stamp top-level taskAssignmentAuthority: 'memory-core.v1'; caller-authored Task JSON cannot forge this provenance.
  • The winning broadcast Submitted → Working update atomically persists state, bound claimant, provenance marker, and one lastModifiedAt.
  • A losing concurrent claimant reads back the winner's state and assignee and cannot overwrite either.
  • After Bob claims a broadcast Task, Charlie cannot execute Working → InputRequired, Completed, or Failed; Bob can.
  • Existing originator transitions (Submitted → Canceled, InputRequired → Working) remain valid when their ownership premise is sound and do not erase the canonical assignee; an ownerless legacy broadcast may still be canceled from Submitted but cannot resume from InputRequired.
  • Legacy direct Tasks without assignee backfill only from their one direct AgentIdentity recipient.
  • Legacy broadcast Tasks beyond Submitted without trusted assignment provenance fail closed with an explicit owner-unknown diagnostic.
  • TASK_STATE_CHANGED targets only originator/canonical assignee and reports lastModifiedAt.
  • get_message and list_messages surface the canonical assignee; cross-process/cold-cache reads match SQLite.
  • TTL expiry preserves the assignee and advances the existing transition timestamp without broadening authority.
  • OpenAPI, JSDoc, and learn/agentos/A2A.md explain that task.assignee is server-owned and that broadcast audience is not post-claim ownership.
  • Focused Memory Core unit suites pass through the canonical Playwright unit configuration; no default Playwright command is used.

Out of Scope

  • Building the GitHub Workflow lifecycle raw-facts adapter.
  • Emitting or ordering LifecycleFrontier rows.
  • Hook projection storage/readers, Bird Views, fleet-awareness-read, or #14620 UI.
  • New Task states, task history/artifacts, reassignment/lease takeover, or automatic retry.
  • A new graph edge/ontology for Task assignment.
  • Treating ordinary broadcasts or issue assignments as actionable lifecycle work.

Avoided Traps

  • State-only “lock”: serializes the first write but leaves later authority cohort-wide.
  • Trust caller task.assignee: lets a message author spoof transition/wake authority.
  • Use SENT_TO: AGENT:* forever: confuses eligible claimants with the winning assignee.
  • Add a second assignment edge: creates dual state and atomicity problems when the Task JSON already owns transition state.
  • Build Fleet/lifecycle consumers first: makes an ambiguous source row look authoritative and multiplies repair cost.

Related

  • Parent: #15100
  • Source architecture: ADR 0035
  • Graduated source: Discussion #15090
  • Historical claim-and-lock implementation: #10338
  • Downstream consumer remains blocked: #14620

Origin Session ID: f95e01ff-ba36-409a-98af-573263fab247

Retrieval Hint: A2A broadcast Task winning assignee transitionTask lifecycle frontier Retrieval Hint: task.assignee server-owned claim-and-lock lastModifiedAt

Live latest-open sweep: checked the latest 20 open issues at 2026-07-12T20:38Z; no equivalent found. Recent all-state A2A claim sweep: only Emmy's earlier #15100 lane-intent overlaps this scope; no foreign collision.

Intake Contract Refinement — WAL Replay Durability

The accepted Message WAL is immutable send-time intake truth. A full graph re-projection over an existing MESSAGE must therefore be storage-neutral for the MESSAGE node: do not read mutable Task/read/tombstone fields and then whole-node-upsert a merged snapshot. That still leaves a stale-writer window in which a peer transition can land after the read and be rewound by the replay write. If the MESSAGE row already exists, replay skips the node write entirely and may repair only independently missing edges/markers.

Post-marker node loss is a different failure class. Once the MESSAGE row is gone, intake WAL cannot prove whether the Task was still Submitted, already claimed, or terminal. Repair must never resurrect the send-time Task as claimable work. It restores the envelope as state: Unknown, assignee: null, with server provenance but no synthesized transition clock; that state is observable and non-claimable. A genuinely never-projected marker-less record still takes its WAL send-time Task on first projection.

Additional acceptance criteria:

  • Existing-node full replay performs no MESSAGE-node write; an interposed peer transition remains exact in SQLite/cache instead of being rewound by a stale snapshot.
  • Post-marker recovery of a missing Task MESSAGE restores non-claimable Unknown + assignee:null and cannot recreate WAL Submitted.
  • After a broadcast Task is claimed, replaying its original accepted WAL record preserves the canonical winning assignee, exact assignment-authority marker, state, and committed lastModifiedAt.

Evidence: focused existing-node replay, interposed-transition, and delete-after-claim recovery falsifiers alongside the existing read/retraction replay guards.

tobiu referenced in commit 69554b0 - "fix(ai): persist authoritative A2A Task ownership (#15106) (#15111)" on 12:41 AM
tobiu closed this issue on 12:41 AM