From f0fb29889c02db0f549ea2811286d060d5ffe399 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 11 Aug 2026 14:30:37 +0300 Subject: [PATCH 1/7] docs(adr): add ADR 0086 for conversation surface participation Record the decision to introduce conversation.Client (parallel to tracker.Client) so custom agents can participate in GitHub Discussions and other chat systems without extending forge.Client, and extend NormalizedEvent with conversation category metadata. Signed-off-by: Barak Korren Co-authored-by: Cursor --- ...slash-command-entity-context-separation.md | 3 + ...rsation-surface-for-agent-participation.md | 126 ++++++++++++++++++ docs/architecture.md | 14 +- docs/normative/normalized-event/v1/README.md | 20 ++- .../examples/discussion-fs-vouch-comment.json | 38 ++++++ .../v1/normalized-event.schema.json | 72 +++++++++- 6 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 docs/ADRs/0086-conversation-surface-for-agent-participation.md create mode 100644 docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json diff --git a/docs/ADRs/0076-slash-command-entity-context-separation.md b/docs/ADRs/0076-slash-command-entity-context-separation.md index 2f142c20dc..b4cdcab800 100644 --- a/docs/ADRs/0076-slash-command-entity-context-separation.md +++ b/docs/ADRs/0076-slash-command-entity-context-separation.md @@ -17,6 +17,9 @@ Date: 2026-07-28 Accepted +Extends entity-context rules for conversation surfaces in +[ADR 0086](0086-conversation-surface-for-agent-participation.md). + Refines [ADR 0002](0002-initial-fullsend-design.md) (initial design) and reinforces [ADR 0020](0020-composable-single-responsibility-agents-with-individual-sandboxes.md) (single-responsibility agents). diff --git a/docs/ADRs/0086-conversation-surface-for-agent-participation.md b/docs/ADRs/0086-conversation-surface-for-agent-participation.md new file mode 100644 index 0000000000..57b25ee361 --- /dev/null +++ b/docs/ADRs/0086-conversation-surface-for-agent-participation.md @@ -0,0 +1,126 @@ +--- +title: "86. Conversation surface for agent participation" +status: Accepted +relates_to: + - agent-architecture + - agent-infrastructure + - security-threat-model +topics: + - discussions + - dispatch + - conversation + - tracker + - portability + - slash-commands +--- + +# 86. Conversation surface for agent participation + +Date: 2026-08-11 + +## Status + +Accepted + +Builds on [ADR 0005](0005-forge-abstraction-layer.md), +[ADR 0017](0017-credential-isolation-for-sandboxed-agents.md) / +[ADR 0046](0046-host-side-api-server-design.md), +[ADR 0054](0054-require-authorization-on-all-agent-dispatch-paths.md), +[ADR 0058](0058-agent-registration.md), +[ADR 0061](0061-harness-cel-dispatch.md), +[ADR 0063](0063-polling-based-work-discovery.md), and +[ADR 0076](0076-slash-command-entity-context-separation.md). +Aligns with the `tracker.Client` split +([#5988](https://github.com/fullsend-ai/fullsend/issues/5988)): domain +interfaces stay narrow; `forge.Client` remains git-hosting only. + +## Context + +Agents already act on forge **work items** and **change proposals** via slash +commands and host/post-script comments. GitHub Discussions (and later Slack, +Discord, Matrix, GitLab discussions) are a different surface: threaded community +conversation that must not be shoehorned into `work_item` without breaking +entity-context rules ([ADR 0076](0076-slash-command-entity-context-separation.md)). + +`forge.Client` is scoped to git-hosting. Issue content for non-forge backends +(Jira) already moves to `tracker.Client` rather than expanding the forge +interface. Slack/Discord/Matrix are likewise not forges — conversation +read/write must not grow `forge.Client`. + +Discussions also introduce **categories** (partition + format/permission +policy) distinct from **labels** (multi-tag triage). The event model must +expose both without collapsing them. + +## Options + +### A. Special-case GitHub Discussions in workflows + +Fastest for GitHub; GitHub-centric and duplicates auth/writeback. + +### B. Always-on chat bot outside the execution stack + +Low latency; bypasses unidirectional control, mint/OIDC identity, and harness +registration ([ADR 0016](0016-unidirectional-control-flow.md)). + +### C. Extend `forge.Client` with Conversation/Message methods + +Pulls non-forge chat into the forge package and fights the `tracker.Client` +split. + +### D. Portable Conversation surface via `conversation.Client` (chosen) + +Parallel to `tracker.Client`: narrow domain interface + dispatch/event reuse, +with per-backend adapters. + +## Decision + +Adopt **Option D**. + +### Domain model + +| Relation | Cardinality | Notes | +|----------|-------------|--------| +| Category → Conversation | **1:M** | Every conversation has exactly one category (GitHub requires it; Slack/Discord map channel → category). | +| Conversation ↔ Label | **M:M** | Same triage tags as issues when the backend supports them (GitHub Discussions do). | +| Conversation → Message | **1:M** | Messages (comments/replies) belong to one conversation. | +| Message → Category / Label | **none** | Messages inherit routing context from their parent conversation; they are not categorized or labeled independently. | + +**Category ≠ label.** Categories are a single exclusive partition with +backend-defined format/permission semantics (e.g. GitHub announcement = +maintain/admin create; Q&A = answerable). Labels are additive tags on the +conversation. Adapters must not encode the category name as a synthetic label. + +**Permissions.** Platform authorization remains +[ADR 0054](0054-require-authorization-on-all-agent-dispatch-paths.md) (effective +repo/collaborator role on `actor`). Category format is **routing and policy +metadata** for harness CEL (e.g. only vouch agents match +`category.slug == "vouch-request"`), not a second auth system. Backends may +still reject creates/replies the actor cannot perform; that is adapter error +handling. + +### Architecture seams + +1. **`conversation.Client`:** `internal/conversation` with Conversation/Message + APIs (and category metadata on Conversation). GitHub Discussions first; + Slack/peers implement directly. **Not** on `forge.Client` or + `tracker.Client`. +2. **`NormalizedEvent`:** `entity.kind: conversation`. Require + `state.conversation.category` (`name` required; `id`/`slug`/`format` + optional). Reuse `state.labels` for conversation labels only. Message + events use `transition.kind: comment_added` with `transition.comment`; + category and labels still describe the **parent conversation**. See + [normative v1](../normative/normalized-event/v1/). +3. **Ingress / egress / identity / security:** shim + dispatch drivers; + host-mediated writeback via `conversation.Client` (tier 1 default, host API + for mid-run); least-privilege Discussions/chat scopes; conversation bodies + untrusted; entity-context rules keep code-mutating slash commands off + conversations ([ADR 0076](0076-slash-command-entity-context-separation.md)). + +## Consequences + +- Harnesses route with CEL on both `state.conversation.category` and + `state.labels` (e.g. category selects the agent; labels refine). +- Slack becomes input-driver + `conversation.Client` adapter work; channel maps + to category, thread maps to conversation. +- Domain split: `forge.Client` / `tracker.Client` / `conversation.Client`. +- Linking a conversation to a work item for `/fs-code` remains a follow-on. diff --git a/docs/architecture.md b/docs/architecture.md index 70ddb56258..181c858a03 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,6 +42,7 @@ the dedicated org-level `/.fullsend` config repo is deprecated **Decided:** - Forge abstraction: all forge operations go through the `forge.Client` interface, keeping the rest of the codebase forge-agnostic ([ADR 0005](ADRs/0005-forge-abstraction-layer.md)). +- Conversation surface: agents participate in GitHub Discussions and later other chat systems through a narrow `conversation.Client` (parallel to `tracker.Client` for issue content), not by extending `forge.Client`. Conversations have exactly one category (1:M) and optional M:M labels; messages inherit that context and are not labeled ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md)). - Installation model: ordered layer stack (install forward, uninstall reverse, analyze for status reporting) with idempotent operations. Current stack: config-repo → workflows → vendor-binary → secrets → inference → dispatch → enrollment ([ADR 0006](ADRs/0006-ordered-layer-model.md)). - Cross-repo dispatch: enrolled repos call `.fullsend` via `workflow_call`; a dispatch workflow mints OIDC tokens exchanged at a central token mint (GCP Cloud Function or Cloudflare Worker) for scoped GitHub App installation tokens per agent role. App PEM secrets are stored in Secret Manager (GCF mint), Worker secrets (CF mint), or the local filesystem (standalone mint), not the config repo ([ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md)). - Shim workflow security: `pull_request_target` prevents PR authors from modifying the shim workflow. No long-lived secrets flow through the shim — OIDC tokens are issued by the GitHub runtime and scoped to the workflow run ([ADR 0009](ADRs/0009-pull-request-target-in-shim-workflows.md)). @@ -210,6 +211,7 @@ One concrete implementation option is [`oidcx`](https://github.com/oxidecomputer - How are credentials rotated and revoked, and who has authority to do that? - Does the identity provider integrate with existing secrets management, or is it a new system? - How will per-role identity work on GitLab and Forgejo, which lack GitHub's app manifest flow? GitLab uses a bot PAT credential model (via OIDC/WIF or protected CI/CD variable) — see [ADR 0067](ADRs/0067-gitlab-cron-polling-event-dispatch.md). +- Which agent roles need Discussions (or other chat) write scopes, and how do those scopes map onto named mint privilege levels? Conversation participation requires least-privilege identity deltas per [ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md). ## Agent Dispatch and Coordination Layer @@ -230,6 +232,12 @@ The existing design principle is that [the repo is the coordinator](problems/age as webhooks ([ADR 0063](ADRs/0063-polling-based-work-discovery.md)). Initial scope is per-repo mode only. - GitLab dispatch uses cron-polled scheduled pipelines for issue/comment/label events and native `merge_request_event` for MR events. No webhook bridge required (see [ADR 0067](ADRs/0067-gitlab-cron-polling-event-dispatch.md)). +- Conversation participation: GitHub Discussions (and future chat systems) enter + dispatch as `NormalizedEvent` entities with `entity.kind: conversation`, reuse + CEL harness triggers and ADR 0054 authorization, and write back through + host/post-script or host-side API servers via `conversation.Client` — not a + separate always-on chat bot and not an extension of `forge.Client` + ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md)). **Open questions:** @@ -240,6 +248,10 @@ The existing design principle is that [the repo is the coordinator](problems/age - How does work assignment interact with the backlog/priority agent described in [agent-architecture.md](problems/agent-architecture.md)? - What happens when work needs to be cancelled, retried, or reassigned? - Does the coordinator need state (a queue, a lock, a claim system), or can it be stateless and event-driven? +- When should a conversation be linked to a work item (e.g. Discussion → issue) + so a conversation-native agent can hand off to `/fs-code` without violating + entity-context separation ([ADR 0076](ADRs/0076-slash-command-entity-context-separation.md), + [ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md))? ## Policy Store @@ -333,7 +345,7 @@ ADR 0002: [Building block 1](ADRs/0002-initial-fullsend-design.md#1-webhook--dis ### 2. Slash-command parser + ACL -Parses `/fs-triage`, `/fs-code`, `/fs-review`, and related commands and enforces who is allowed to invoke each. Commands are restricted to the entity context where their agent's inputs exist — `/fs-code` dispatches only from issues (no associated PR), `/fs-fix` and `/fs-review` only from PRs ([ADR 0076](ADRs/0076-slash-command-entity-context-separation.md)). +Parses `/fs-triage`, `/fs-code`, `/fs-review`, and related commands and enforces who is allowed to invoke each. Commands are restricted to the entity context where their agent's inputs exist — `/fs-code` dispatches only from issues (no associated PR), `/fs-fix` and `/fs-review` only from PRs ([ADR 0076](ADRs/0076-slash-command-entity-context-separation.md)). Conversation surfaces (GitHub Discussions and future chat systems) are a separate entity context: conversation-native agents may listen there, but code-mutating slash commands do not ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md)). ADR 0002: [Building block 2](ADRs/0002-initial-fullsend-design.md#2-slash-command-parser--acl). ### 3. Label state machine guard diff --git a/docs/normative/normalized-event/v1/README.md b/docs/normative/normalized-event/v1/README.md index 7bc478f056..645f4d10ea 100644 --- a/docs/normative/normalized-event/v1/README.md +++ b/docs/normative/normalized-event/v1/README.md @@ -29,6 +29,15 @@ input, and **Jira poll** input: is the production GitLab adapter ([ADR 0067](../../../ADRs/0067-gitlab-cron-polling-event-dispatch.md)); `jira-poll` is the production Jira poll adapter; `json` supports tests and replay. +- `entity.kind: conversation` covers GitHub Discussions and future chat + systems ([ADR 0086](../../../ADRs/0086-conversation-surface-for-agent-participation.md)). + GitHub Discussion adapters are planned under `gha-event`; additional + `source.system` values (e.g. Slack) are non-breaking enum additions when + those drivers land. +- Conversations carry **`state.conversation.category`** (exactly one; 1:M + category→conversation) separately from **`state.labels`** (M:M tags on the + conversation). Messages have neither; `comment_added` events still snapshot + the parent conversation's category and labels. ## Versioning @@ -48,7 +57,7 @@ Input drivers map native forge events into this struct: | Driver | Source | v1 status | |--------|--------|-----------| -| `gha-event` | `GITHUB_EVENT_PATH` + `gh` snapshot for labels and change-proposal metadata | Production | +| `gha-event` | `GITHUB_EVENT_PATH` + `gh` snapshot for labels and change-proposal metadata | Production; Discussions → `entity.kind: conversation` planned ([ADR 0086](../../../ADRs/0086-conversation-surface-for-agent-participation.md)) | | `gitlab-poll` | GitLab CI event payload (cron-polled; [ADR 0067](../../../ADRs/0067-gitlab-cron-polling-event-dispatch.md)) | Production (poll) | | `jira-poll` | Jira issue search + changelog/comments since `lastCheck` ([jira-poll-adapter.md](jira-poll-adapter.md), [ADR 0063](../../../ADRs/0063-polling-based-work-discovery.md)) | Production (poll) | | `json` | stdin or `--input-file` | Tests, replay | @@ -56,6 +65,9 @@ Input drivers map native forge events into this struct: Adapters must populate: - `state.labels` when routing guards or label-based triggers apply. +- `state.conversation` (with `category.name` at minimum) whenever + `entity.kind` is `conversation` — including message/`comment_added` events. + Do not encode the category as a synthetic label. - `state.change_proposal` (including `head_ref`, `base_ref`, and `head_sha` when known) whenever a matched harness needs change-proposal execution context. Webhook payloads are often incomplete — adapters should fill gaps via GitHub @@ -168,6 +180,12 @@ event.transition.kind == "review_submitted" event.transition.kind == "comment_added" && event.transition.comment.command == "/fs-fix" && !event.state.change_proposal.is_fork + +// Conversation-native agent on Discussion slash command (ADR 0086) +event.entity.kind == "conversation" + && event.state.conversation.category.slug == "vouch-request" + && event.transition.kind == "comment_added" + && event.transition.comment.command == "/fs-vouch" ``` See [`examples/`](examples/) for matching `NormalizedEvent` fixtures. diff --git a/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json b/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json new file mode 100644 index 0000000000..8b492f924f --- /dev/null +++ b/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json @@ -0,0 +1,38 @@ +{ + "repo": "fullsend-ai/fullsend", + "entity": { + "kind": "conversation", + "id": 5182, + "url": "https://github.com/fullsend-ai/fullsend/discussions/5182" + }, + "actor": { + "id": "contributor", + "kind": "human", + "role": "write", + "is_entity_author": true + }, + "transition": { + "kind": "comment_added", + "comment": { + "command": "/fs-vouch", + "body": "/fs-vouch please review my request", + "instruction": "please review my request" + } + }, + "state": { + "labels": ["needs-triage"], + "conversation": { + "category": { + "id": "DIC_kwDOExample", + "name": "Vouch Request", + "slug": "vouch-request", + "format": "discussion" + } + } + }, + "source": { + "system": "github", + "raw_type": "discussion_comment", + "raw_action": "created" + } +} diff --git a/docs/normative/normalized-event/v1/normalized-event.schema.json b/docs/normative/normalized-event/v1/normalized-event.schema.json index ef12468bb3..1773a60e37 100644 --- a/docs/normative/normalized-event/v1/normalized-event.schema.json +++ b/docs/normative/normalized-event/v1/normalized-event.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://fullsend.ai/normative/normalized-event/v1/normalized-event.schema.json", "title": "NormalizedEvent", - "description": "Forge-neutral routing input for fullsend dispatch and harness CEL trigger evaluation (ADR 0061). v1 targets GitHub Actions webhooks, GitLab cron-poll input (ADR 0067), and Jira poll input (ADR 0063).", + "description": "Forge-neutral routing input for fullsend dispatch and harness CEL trigger evaluation (ADR 0061). v1 targets GitHub Actions webhooks (including Discussions via entity.kind conversation per ADR 0086), GitLab cron-poll input (ADR 0067), and Jira poll input (ADR 0063).", "type": "object", "additionalProperties": false, "required": ["repo", "entity", "transition", "actor", "state", "source"], @@ -83,6 +83,25 @@ } } } + }, + { + "if": { + "properties": { + "entity": { + "properties": { "kind": { "const": "conversation" } }, + "required": ["kind"] + } + }, + "required": ["entity"] + }, + "then": { + "properties": { + "state": { + "required": ["conversation"] + } + }, + "required": ["state"] + } } ], "$defs": { @@ -100,8 +119,8 @@ "properties": { "kind": { "type": "string", - "enum": ["work_item", "change_proposal"], - "description": "Forge-neutral entity kind (issue vs PR/MR)." + "enum": ["work_item", "change_proposal", "conversation"], + "description": "Forge-neutral entity kind (issue vs PR/MR vs discussion/chat thread). See ADR 0086." }, "id": { "type": "integer", @@ -298,7 +317,52 @@ "minLength": 1, "maxLength": 255 }, - "description": "Snapshot of label names on the entity at event time." + "description": "Snapshot of label names on the entity at event time. For entity.kind conversation, these are labels on the conversation (not on individual messages). Category is not a label — see state.conversation.category (ADR 0086)." + }, + "conversation": { + "type": "object", + "additionalProperties": false, + "required": ["category"], + "properties": { + "category": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Backend-stable category id when available (e.g. GitHub GraphQL node id)." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable category name (GitHub Discussion category, Slack/Discord channel name mapped as category)." + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "URL-safe or backend slug when available (e.g. GitHub category slug)." + }, + "format": { + "type": "string", + "enum": [ + "discussion", + "question_answer", + "announcement", + "poll", + "other" + ], + "description": "Optional category format / permission class. GitHub: open-ended→discussion, Q&A→question_answer, announcement→announcement, polls→poll. Chat backends without formats omit or use other." + } + }, + "description": "Exclusive category of the conversation (1:M category→conversation). Distinct from state.labels (M:M tags)." + } + }, + "description": "Required when entity.kind is conversation (ADR 0086). Snapshot of conversation-level metadata at event time, including when the transition is comment_added on a message." }, "change_proposal": { "type": "object", From 62739d23d29b5e9665adf5a4ff611d2a947748e7 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 11 Aug 2026 14:43:46 +0300 Subject: [PATCH 2/7] docs(adr): guard optional fields in conversation CEL example Require has() checks for optional comment.command and category.slug in the NormalizedEvent conversation trigger example so copy-paste triggers do not fail on valid events that omit those fields. Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/normative/normalized-event/v1/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/normative/normalized-event/v1/README.md b/docs/normative/normalized-event/v1/README.md index 645f4d10ea..bc05f2484a 100644 --- a/docs/normative/normalized-event/v1/README.md +++ b/docs/normative/normalized-event/v1/README.md @@ -183,9 +183,11 @@ event.transition.kind == "comment_added" // Conversation-native agent on Discussion slash command (ADR 0086) event.entity.kind == "conversation" - && event.state.conversation.category.slug == "vouch-request" && event.transition.kind == "comment_added" + && has(event.transition.comment.command) && event.transition.comment.command == "/fs-vouch" + && has(event.state.conversation.category.slug) + && event.state.conversation.category.slug == "vouch-request" ``` See [`examples/`](examples/) for matching `NormalizedEvent` fixtures. From a3167c23b50e8465a94bb22f06f20fc11d6584fd Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 11 Aug 2026 21:11:55 +0300 Subject: [PATCH 3/7] docs(adr): clarify conversation category.format vs GitHub GraphQL Document that GitHub DiscussionCategory only exposes isAnswerable for format derivation, drop implied announcement/poll API mapping, and address low review nits on is_entity_author and ADR 0076 CEL enforcement. Signed-off-by: Barak Korren Co-authored-by: Cursor --- ...rsation-surface-for-agent-participation.md | 30 ++++++++++++------- .../examples/discussion-fs-vouch-comment.json | 3 +- .../v1/normalized-event.schema.json | 4 +-- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/docs/ADRs/0086-conversation-surface-for-agent-participation.md b/docs/ADRs/0086-conversation-surface-for-agent-participation.md index 57b25ee361..57c39dc0e2 100644 --- a/docs/ADRs/0086-conversation-surface-for-agent-participation.md +++ b/docs/ADRs/0086-conversation-surface-for-agent-participation.md @@ -47,9 +47,9 @@ entity-context rules ([ADR 0076](0076-slash-command-entity-context-separation.md interface. Slack/Discord/Matrix are likewise not forges — conversation read/write must not grow `forge.Client`. -Discussions also introduce **categories** (partition + format/permission -policy) distinct from **labels** (multi-tag triage). The event model must -expose both without collapsing them. +Discussions also introduce **categories** (partition + optional format hints) +distinct from **labels** (multi-tag triage). The event model must expose both +without collapsing them. ## Options @@ -85,19 +85,27 @@ Adopt **Option D**. | Conversation → Message | **1:M** | Messages (comments/replies) belong to one conversation. | | Message → Category / Label | **none** | Messages inherit routing context from their parent conversation; they are not categorized or labeled independently. | -**Category ≠ label.** Categories are a single exclusive partition with -backend-defined format/permission semantics (e.g. GitHub announcement = -maintain/admin create; Q&A = answerable). Labels are additive tags on the -conversation. Adapters must not encode the category name as a synthetic label. +**Category ≠ label.** Categories are a single exclusive partition. Some +backends expose category *format* hints (open discussion, Q&A, announcement, +poll); those are optional routing metadata, not labels. Labels are additive +tags on the conversation. Adapters must not encode the category name as a +synthetic label. **Permissions.** Platform authorization remains [ADR 0054](0054-require-authorization-on-all-agent-dispatch-paths.md) (effective -repo/collaborator role on `actor`). Category format is **routing and policy +repo/collaborator role on `actor`). Category name/slug/format are **routing metadata** for harness CEL (e.g. only vouch agents match `category.slug == "vouch-request"`), not a second auth system. Backends may still reject creates/replies the actor cannot perform; that is adapter error handling. +GitHub's public GraphQL `DiscussionCategory` exposes `isAnswerable` but no +announcement/poll/discussion format field. Adapters MUST map +`isAnswerable == true` to `format: question_answer` and SHOULD omit `format` +otherwise unless they document an explicit heuristic. UI-only create +restrictions (e.g. Announcements limited to maintain/admin) are not queryable +via that API and MUST NOT be treated as enforceable from `format` alone. + ### Architecture seams 1. **`conversation.Client`:** `internal/conversation` with Conversation/Message @@ -113,8 +121,10 @@ handling. 3. **Ingress / egress / identity / security:** shim + dispatch drivers; host-mediated writeback via `conversation.Client` (tier 1 default, host API for mid-run); least-privilege Discussions/chat scopes; conversation bodies - untrusted; entity-context rules keep code-mutating slash commands off - conversations ([ADR 0076](0076-slash-command-entity-context-separation.md)). + untrusted. Entity-context separation keeps code-mutating slash commands off + conversations; enforcement is harness CEL `trigger` expressions over + `entity.kind` (target state in + [ADR 0076](0076-slash-command-entity-context-separation.md)). ## Consequences diff --git a/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json b/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json index 8b492f924f..9a35f384a3 100644 --- a/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json +++ b/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json @@ -25,8 +25,7 @@ "category": { "id": "DIC_kwDOExample", "name": "Vouch Request", - "slug": "vouch-request", - "format": "discussion" + "slug": "vouch-request" } } }, diff --git a/docs/normative/normalized-event/v1/normalized-event.schema.json b/docs/normative/normalized-event/v1/normalized-event.schema.json index 1773a60e37..cb12470e37 100644 --- a/docs/normative/normalized-event/v1/normalized-event.schema.json +++ b/docs/normative/normalized-event/v1/normalized-event.schema.json @@ -301,7 +301,7 @@ }, "is_entity_author": { "type": "boolean", - "description": "True when actor.id is the author of the work item or change proposal." + "description": "True when actor.id is the author of the work item, change proposal, or conversation." } } }, @@ -356,7 +356,7 @@ "poll", "other" ], - "description": "Optional category format / permission class. GitHub: open-ended→discussion, Q&A→question_answer, announcement→announcement, polls→poll. Chat backends without formats omit or use other." + "description": "Optional portable category format for routing. Not a permission authority (ADR 0054). GitHub GraphQL DiscussionCategory exposes isAnswerable but no announcement/poll/discussion format field: adapters MUST set question_answer when isAnswerable is true, and SHOULD omit format otherwise unless they document an explicit heuristic (e.g. name/slug matching). UI-only GitHub create restrictions (e.g. announcement maintain/admin) are not queryable via the public API and MUST NOT be inferred solely from this field." } }, "description": "Exclusive category of the conversation (1:M category→conversation). Distinct from state.labels (M:M tags)." From dc083a00e659c3f506a95caa78cc5328c7e44b1e Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 18 Aug 2026 12:53:04 +0300 Subject: [PATCH 4/7] chore(normevent): gofmt conversation types Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/normevent/event.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/normevent/event.go b/internal/normevent/event.go index 18f7bcc2ba..ef230f80ba 100644 --- a/internal/normevent/event.go +++ b/internal/normevent/event.go @@ -119,9 +119,9 @@ type Actor struct { // State is a snapshot of entity metadata at event time. type State struct { - Labels []string `json:"labels"` - ChangeProposal *ChangeProposal `json:"change_proposal,omitempty"` - Conversation *ConversationState `json:"conversation,omitempty"` + Labels []string `json:"labels"` + ChangeProposal *ChangeProposal `json:"change_proposal,omitempty"` + Conversation *ConversationState `json:"conversation,omitempty"` } // ConversationState is conversation-level metadata (ADR 0086). From ccf2212bf3ab6ab5dad9cb75dd8be82039884d3d Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 18 Aug 2026 14:21:10 +0300 Subject: [PATCH 5/7] test(normevent): cover conversation Validate error paths Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/normevent/event_test.go | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/internal/normevent/event_test.go b/internal/normevent/event_test.go index d3fbf7cb67..42f0de4471 100644 --- a/internal/normevent/event_test.go +++ b/internal/normevent/event_test.go @@ -92,3 +92,55 @@ func TestParseJSON_ConversationExample(t *testing.T) { cat := conv["category"].(map[string]any) assert.Equal(t, "vouch-request", cat["slug"]) } + +func TestValidate_ConversationRules(t *testing.T) { + base := Event{ + Repo: "fullsend-ai/fullsend", + Entity: Entity{ + Kind: EntityConversation, + ID: 1, + URL: "https://github.com/fullsend-ai/fullsend/discussions/1", + }, + Transition: Transition{Kind: TransitionOpened}, + Actor: Actor{ + ID: "user", + Kind: ActorHuman, + Role: RoleWrite, + }, + State: State{ + Labels: []string{}, + Conversation: &ConversationState{ + Category: ConversationCategory{Name: "General"}, + }, + }, + Source: Source{System: SystemGitHub, RawType: "discussion"}, + } + + t.Run("missing_conversation", func(t *testing.T) { + ev := base + ev.State.Conversation = nil + err := ev.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "state.conversation required") + }) + + t.Run("empty_category_name", func(t *testing.T) { + ev := base + ev.State.Conversation = &ConversationState{ + Category: ConversationCategory{Name: " "}, + } + err := ev.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "category.name") + }) + + t.Run("conversation_forbidden_on_work_item", func(t *testing.T) { + ev := base + ev.Entity.Kind = EntityWorkItem + ev.Entity.URL = "https://github.com/fullsend-ai/fullsend/issues/1" + ev.Source.RawType = "issues" + err := ev.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "state.conversation forbidden") + }) +} From 103899b4e173d15d5d5865317e12eb98ae40c443 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Wed, 19 Aug 2026 23:35:49 +0300 Subject: [PATCH 6/7] docs(adr): revise conversation threading to comment parent_id Align ADR 0086 with Slack conversation/thread naming and GitHub discussion_comment parent pointers. Require comment.id and parent_id (parent_id == id on thread roots) instead of state.conversation.thread. Signed-off-by: Barak Korren Co-authored-by: Cursor --- ...rsation-surface-for-agent-participation.md | 106 +++++++++++------- docs/architecture.md | 22 ++-- docs/guides/user/cel-triggers-reference.md | 4 +- docs/normative/normalized-event/v1/README.md | 18 ++- .../examples/discussion-fs-vouch-comment.json | 2 + .../v1/normalized-event.schema.json | 22 +++- internal/normevent/event.go | 19 +++- internal/normevent/event_test.go | 54 +++++++++ 8 files changed, 181 insertions(+), 66 deletions(-) diff --git a/docs/ADRs/0086-conversation-surface-for-agent-participation.md b/docs/ADRs/0086-conversation-surface-for-agent-participation.md index bf11d67526..51a1c14d8d 100644 --- a/docs/ADRs/0086-conversation-surface-for-agent-participation.md +++ b/docs/ADRs/0086-conversation-surface-for-agent-participation.md @@ -9,6 +9,7 @@ topics: - discussions - dispatch - conversation + - thread - tracker - portability - slash-commands @@ -41,18 +42,21 @@ interfaces stay narrow; `forge.Client` remains git-hosting only. Agents already act on forge **work items** and **change proposals** via slash commands and host/post-script comments. GitHub Discussions (and later Slack, -Discord, Matrix, GitLab discussions) are a different surface: threaded community -conversation that must not be shoehorned into `work_item` without breaking -entity-context rules ([ADR 0076](0076-slash-command-entity-context-separation.md)). +Discord, Matrix, GitLab discussions) are a different surface: community chat +that must not be shoehorned into `work_item` without breaking entity-context +rules ([ADR 0076](0076-slash-command-entity-context-separation.md)). `forge.Client` is scoped to git-hosting. Issue content for non-forge backends -(Jira) already moves to `tracker.Client` rather than expanding the forge -interface. Slack/Discord/Matrix are likewise not forges — conversation -read/write must not grow `forge.Client`. +(Jira) already moves to `tracker.Client`. Slack and peers are not forges — +chat read/write must not grow `forge.Client`. -Discussions also introduce **categories** (partition + optional format hints) -distinct from **labels** (multi-tag triage). The event model must expose both -without collapsing them. +Slack's API already names the container a **conversation** (channel/DM) and +replies under a parent a **thread**. Fullsend should use those words so +adapters map by name, not by overloaded metaphors. GitHub Discussions fit the +same shape: a Discussion is a conversation; a top-level comment plus its flat +replies is a thread (GitHub allows only one reply nesting level). Native +webhook payloads associate replies via a parent pointer on the message +(`comment.parent_id` on `discussion_comment`), not a separate thread object. ## Options @@ -83,57 +87,73 @@ Adopt **Option D**. | Relation | Cardinality | Notes | |----------|-------------|--------| -| Category → Conversation | **1:M** | Every conversation has exactly one category (GitHub requires it; Slack/Discord map channel → category). | -| Conversation ↔ Label | **M:M** | Same triage tags as issues when the backend supports them (GitHub Discussions do). | -| Conversation → Message | **1:M** | Messages (comments/replies) belong to one conversation. | -| Message → Category / Label | **none** | Messages inherit routing context from their parent conversation; they are not categorized or labeled independently. | - -**Category ≠ label.** Categories are a single exclusive partition. Some -backends expose category *format* hints (open discussion, Q&A, announcement, -poll); those are optional routing metadata, not labels. Labels are additive +| Category → Conversation | **1:M** | Exclusive partition (GitHub Discussion category; Slack channel **section** when mapped). | +| Conversation → Thread | **1:M** | A thread is the top-level message plus replies that name it as parent. | +| Thread → Message | **1:M** | Flat within the thread on GitHub (reply-to-reply nesting rejected). | +| Conversation ↔ Label | **M:M** | Triage tags on the conversation when the backend supports them. | +| Message / Thread → Category / Label | **none** | Inherit routing context from the parent conversation. | + +**Backend mapping** + +| Fullsend | Slack API / UI | GitHub Discussions | +|----------|----------------|--------------------| +| Category | Channel section (optional grouping) | Discussion category | +| Conversation | Conversation (channel / DM) | Discussion | +| Thread | Thread (`thread_ts`) | Top-level comment + flat replies | +| Message | Message | Comment or reply | + +**Message identity and threading in events.** Comment transitions carry the +message itself: `transition.comment.id` and `transition.comment.parent_id` +(both required for conversation comment events). `parent_id` is always the +thread-root message id — the safe target for a reply in that thread. For a +top-level / thread-root message, adapters set `parent_id` equal to `id`. For a +reply, `parent_id` is the root (GitHub Actions `discussion_comment` uses +`null` parent on the root and a numeric parent on replies — normalize root to +`parent_id == id`; Slack maps `thread_ts`, using the message `ts` when the +message starts the thread). Threads are not a separate `entity.kind`. + +**Category ≠ label.** Categories are a single exclusive partition with optional +*format* hints (open discussion, Q&A, announcement, poll). Labels are additive tags on the conversation. Adapters must not encode the category name as a synthetic label. **Permissions.** Platform authorization remains -[ADR 0054](0054-require-authorization-on-all-agent-dispatch-paths.md) (effective -repo/collaborator role on `actor`). Category name/slug/format are **routing -metadata** for harness CEL (e.g. only vouch agents match -`category.slug == "vouch-request"`), not a second auth system. Backends may -still reject creates/replies the actor cannot perform; that is adapter error -handling. +[ADR 0054](0054-require-authorization-on-all-agent-dispatch-paths.md). Category +and message/parent ids are **routing metadata** for harness CEL, not a second +auth system. GitHub's public GraphQL `DiscussionCategory` exposes `isAnswerable` but no announcement/poll/discussion format field. Adapters MUST map `isAnswerable == true` to `format: question_answer` and SHOULD omit `format` otherwise unless they document an explicit heuristic. UI-only create -restrictions (e.g. Announcements limited to maintain/admin) are not queryable -via that API and MUST NOT be treated as enforceable from `format` alone. +restrictions are not queryable via that API and MUST NOT be treated as +enforceable from `format` alone. ### Architecture seams -1. **`conversation.Client`:** `internal/conversation` with Conversation/Message - APIs (and category metadata on Conversation). GitHub Discussions first; - Slack/peers implement directly. **Not** on `forge.Client` or - `tracker.Client`. +1. **`conversation.Client`:** `internal/conversation` with Conversation, + Thread, and Message APIs (category metadata on Conversation). GitHub + Discussions first; Slack/peers implement directly. **Not** on + `forge.Client` or `tracker.Client`. 2. **`NormalizedEvent`:** `entity.kind: conversation`. Require `state.conversation.category` (`name` required; `id`/`slug`/`format` - optional). Reuse `state.labels` for conversation labels only. Message - events use `transition.kind: comment_added` with `transition.comment`; - category and labels still describe the **parent conversation**. See + optional). On comment transitions, require `transition.comment.id` and + `transition.comment.parent_id` (`parent_id == id` for thread-root messages). + Reuse `state.labels` for conversation labels only. Ingress covers + conversation lifecycle events and message events on conversations. See [normative v1](../normative/normalized-event/v1/). 3. **Ingress / egress / identity / security:** shim + dispatch drivers; - host-mediated writeback via `conversation.Client` (tier 1 default, host API - for mid-run); least-privilege Discussions/chat scopes; conversation bodies - untrusted. Entity-context separation keeps code-mutating slash commands off - conversations; enforcement is harness CEL `trigger` expressions over - `entity.kind` (target state in - [ADR 0076](0076-slash-command-entity-context-separation.md)). + host-mediated writeback via `conversation.Client`; least-privilege + Discussions/chat scopes; conversation and message bodies untrusted. + Entity-context separation keeps code-mutating slash commands off + conversations ([ADR 0076](0076-slash-command-entity-context-separation.md)). ## Consequences -- Harnesses route with CEL on both `state.conversation.category` and - `state.labels` (e.g. category selects the agent; labels refine). -- Slack becomes input-driver + `conversation.Client` adapter work; channel maps - to category, thread maps to conversation. +- Harnesses can route with CEL on `state.conversation.category`, + `transition.comment.id` / `parent_id`, and `state.labels`. +- Slack maps channel→conversation, section→category; `parent_id` is always the + reply target (Slack `thread_ts` / message `ts`; GitHub normalized parent). - Domain split: `forge.Client` / `tracker.Client` / `conversation.Client`. -- Linking a conversation to a work item for `/fs-code` remains a follow-on. +- Linking a conversation or thread to a work item for `/fs-code` remains a + follow-on. diff --git a/docs/architecture.md b/docs/architecture.md index d9560e52fc..5aa7f89b0f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,7 +42,7 @@ the dedicated org-level `/.fullsend` config repo is deprecated **Decided:** - Forge abstraction: all forge operations go through the `forge.Client` interface, keeping the rest of the codebase forge-agnostic ([ADR 0005](ADRs/0005-forge-abstraction-layer.md)). -- Conversation surface: agents participate in GitHub Discussions and later other chat systems through a narrow `conversation.Client` (parallel to `tracker.Client` for issue content), not by extending `forge.Client` ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md)). Conversations have exactly one category (1:M) and optional M:M labels; messages inherit that context and are not labeled independently. +- Conversation surface: agents participate in GitHub Discussions and later other chat systems through a narrow `conversation.Client` (parallel to `tracker.Client` for issue content), not by extending `forge.Client` ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md)). A **conversation** is the container (Discussion / Slack channel) with exactly one category and optional M:M labels; a **thread** is the top-level message plus replies that share its `parent_id` (`parent_id == id` on the root message). - Installation model: ordered layer stack (install forward, uninstall reverse, analyze for status reporting) with idempotent operations. Current stack: config-repo → workflows → vendor-binary → secrets → inference → dispatch → enrollment ([ADR 0006](ADRs/0006-ordered-layer-model.md)). - Cross-repo dispatch: enrolled repos call `.fullsend` via `workflow_call`; a dispatch workflow mints OIDC tokens exchanged at a central token mint (GCP Cloud Function or Cloudflare Worker) for scoped GitHub App installation tokens per agent role. App PEM secrets are stored in Secret Manager (GCF mint), Worker secrets (CF mint), or the local filesystem (standalone mint), not the config repo ([ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md)). - Shim workflow security: `pull_request_target` prevents PR authors from modifying the shim workflow. No long-lived secrets flow through the shim — OIDC tokens are issued by the GitHub runtime and scoped to the workflow run ([ADR 0009](ADRs/0009-pull-request-target-in-shim-workflows.md)). @@ -234,10 +234,12 @@ The existing design principle is that [the repo is the coordinator](problems/age scope is per-repo mode only. - GitLab dispatch uses cron-polled scheduled pipelines for issue/comment/label events and native `merge_request_event` for MR events. No webhook bridge required (see [ADR 0067](ADRs/0067-gitlab-cron-polling-event-dispatch.md)). - Conversation participation: GitHub Discussions (and future chat systems) enter - dispatch as `NormalizedEvent` entities with `entity.kind: conversation`, reuse - CEL harness triggers and ADR 0054 authorization, and write back through - host/post-script or host-side API servers via `conversation.Client` — not a - separate always-on chat bot and not an extension of `forge.Client` + dispatch as `NormalizedEvent` entities with `entity.kind: conversation`, + express threading on `transition.comment.id` / `parent_id` (`parent_id` always + names the thread root), reuse CEL harness triggers and ADR 0054 authorization, + and write back through host/post-script or host-side API servers via + `conversation.Client` — not a separate always-on chat bot and not an + extension of `forge.Client` ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md)). **Open questions:** @@ -249,10 +251,12 @@ The existing design principle is that [the repo is the coordinator](problems/age - How does work assignment interact with the backlog/priority agent described in [agent-architecture.md](problems/agent-architecture.md)? - What happens when work needs to be cancelled, retried, or reassigned? - Does the coordinator need state (a queue, a lock, a claim system), or can it be stateless and event-driven? -- When should a conversation be linked to a work item (e.g. Discussion → issue) - so a conversation-native agent can hand off to `/fs-code` without violating - entity-context separation ([ADR 0076](ADRs/0076-slash-command-entity-context-separation.md), +- When should a conversation or thread be linked to a work item (e.g. Discussion + → issue) so a conversation-native agent can hand off to `/fs-code` without + violating entity-context separation ([ADR 0076](ADRs/0076-slash-command-entity-context-separation.md), [ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md))? +- How should concurrent agent runs that touch the same conversation thread be + coordinated ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md))? ## Policy Store @@ -346,7 +350,7 @@ ADR 0002: [Building block 1](ADRs/0002-initial-fullsend-design.md#1-webhook--dis ### 2. Slash-command parser + ACL -Parses `/fs-triage`, `/fs-code`, `/fs-review`, and related commands and enforces who is allowed to invoke each. Commands are restricted to the entity context where their agent's inputs exist — `/fs-code` dispatches only from issues (no associated PR), `/fs-fix` and `/fs-review` only from PRs ([ADR 0076](ADRs/0076-slash-command-entity-context-separation.md)). Conversation surfaces (GitHub Discussions and future chat systems) are a separate entity context: conversation-native agents may listen there, but code-mutating slash commands do not ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md)). +Parses `/fs-triage`, `/fs-code`, `/fs-review`, and related commands and enforces who is allowed to invoke each. Commands are restricted to the entity context where their agent's inputs exist — `/fs-code` dispatches only from issues (no associated PR), `/fs-fix` and `/fs-review` only from PRs ([ADR 0076](ADRs/0076-slash-command-entity-context-separation.md)). Conversation surfaces (GitHub Discussions and future chat systems) are a separate entity context: conversation-native agents may listen on conversations/threads there, but code-mutating slash commands do not ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md)). ADR 0002: [Building block 2](ADRs/0002-initial-fullsend-design.md#2-slash-command-parser--acl). ### 3. Label state machine guard diff --git a/docs/guides/user/cel-triggers-reference.md b/docs/guides/user/cel-triggers-reference.md index e623df8be4..92163e32cc 100644 --- a/docs/guides/user/cel-triggers-reference.md +++ b/docs/guides/user/cel-triggers-reference.md @@ -48,11 +48,11 @@ The `event` variable has the following top-level fields: | Field | Type | Description | |---|---|---| | `event.repo` | string | Repository path (`owner/repo`) | -| `event.entity.kind` | string | `"work_item"` (issue), `"change_proposal"` (PR), or `"conversation"` (Discussion/chat thread; [ADR 0086](../../ADRs/0086-conversation-surface-for-agent-participation.md)) | +| `event.entity.kind` | string | `"work_item"` (issue), `"change_proposal"` (PR), or `"conversation"` (Discussion / Slack channel; [ADR 0086](../../ADRs/0086-conversation-surface-for-agent-participation.md)) | | `event.entity.id` | int | Issue, PR, or conversation number | | `event.transition.kind` | string | What happened — see [transition kinds](#transition-kinds) | | `event.transition.label` | object | Present only when `kind == "label_changed"` | -| `event.transition.comment` | object | Present only when `kind == "comment_added"` | +| `event.transition.comment` | object | Present only when `kind == "comment_added"` (conversation comments carry `id` and `parent_id`, with `parent_id == id` for thread roots; [ADR 0086](../../ADRs/0086-conversation-surface-for-agent-participation.md)) | | `event.transition.review` | object | Present only when `kind == "review_submitted"` | | `event.actor.id` | string | Forge login of the user or bot that triggered the event | | `event.actor.kind` | string | `"human"` or `"bot"` | diff --git a/docs/normative/normalized-event/v1/README.md b/docs/normative/normalized-event/v1/README.md index 7af77fe28e..4e4f70d85f 100644 --- a/docs/normative/normalized-event/v1/README.md +++ b/docs/normative/normalized-event/v1/README.md @@ -31,13 +31,17 @@ input, and **Jira poll** input: replay. - `entity.kind: conversation` covers GitHub Discussions and future chat systems ([ADR 0086](../../../ADRs/0086-conversation-surface-for-agent-participation.md)). - GitHub Discussion adapters are planned under `gha-event`; additional - `source.system` values (e.g. Slack) are non-breaking enum additions when - those drivers land. + A conversation is the container (Discussion / Slack channel). Threading is + expressed on the message via `transition.comment.id` and + `transition.comment.parent_id` (always the thread-root id; equal to `id` for + top-level messages). GitHub Discussion adapters are planned under + `gha-event`; additional `source.system` values (e.g. Slack) are non-breaking + enum additions when those drivers land. - Conversations carry **`state.conversation.category`** (exactly one; 1:M category→conversation) separately from **`state.labels`** (M:M tags on the - conversation). Messages carry neither category nor labels; `comment_added` - events still snapshot the parent conversation's category and labels. + conversation). Threads and messages carry neither category nor labels; + `comment_added` events still snapshot the parent conversation's category and + labels and MUST include `transition.comment.id` and `parent_id`. ## Versioning @@ -67,7 +71,9 @@ Adapters must populate: - `state.labels` when routing guards or label-based triggers apply. - `state.conversation` (with `category.name` at minimum) whenever `entity.kind` is `conversation` — including message/`comment_added` events. - Do not encode the category as a synthetic label. + Do not encode the category as a synthetic label. On comment transitions, + also set `transition.comment.id` and `parent_id` (`parent_id == id` for + thread-root messages; otherwise the root id). - `state.change_proposal` (including `head_ref`, `base_ref`, and `head_sha` when known) whenever a matched harness needs change-proposal execution context. Webhook payloads are often incomplete — adapters should fill gaps via GitHub diff --git a/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json b/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json index 9a35f384a3..fb315b8bea 100644 --- a/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json +++ b/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json @@ -14,6 +14,8 @@ "transition": { "kind": "comment_added", "comment": { + "id": "DC_kwDOExampleComment", + "parent_id": "DC_kwDOExampleComment", "command": "/fs-vouch", "body": "/fs-vouch please review my request", "instruction": "please review my request" diff --git a/docs/normative/normalized-event/v1/normalized-event.schema.json b/docs/normative/normalized-event/v1/normalized-event.schema.json index ce0e077dc4..b4b569b1a0 100644 --- a/docs/normative/normalized-event/v1/normalized-event.schema.json +++ b/docs/normative/normalized-event/v1/normalized-event.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://fullsend.ai/normative/normalized-event/v1/normalized-event.schema.json", "title": "NormalizedEvent", - "description": "Forge-neutral routing input for fullsend dispatch and harness CEL trigger evaluation (ADR 0061). v1 targets GitHub Actions webhooks (including Discussions via entity.kind conversation per ADR 0086), GitLab cron-poll input (ADR 0067), and Jira poll input (ADR 0063).", + "description": "Forge-neutral routing input for fullsend dispatch and harness CEL trigger evaluation (ADR 0061). v1 targets GitHub Actions webhooks (including Discussions via entity.kind conversation and transition.comment id/parent_id per ADR 0086), GitLab cron-poll input (ADR 0067), and Jira poll input (ADR 0063).", "type": "object", "additionalProperties": false, "required": ["repo", "entity", "transition", "actor", "state", "source"], @@ -127,7 +127,7 @@ "kind": { "type": "string", "enum": ["work_item", "change_proposal", "conversation"], - "description": "Forge-neutral entity kind (issue vs PR/MR vs discussion/chat thread). See ADR 0086." + "description": "Forge-neutral entity kind (issue vs PR/MR vs conversation/Discussion/channel). Threads are not a separate entity.kind; reply association uses transition.comment.parent_id. See ADR 0086." }, "id": { "type": "integer", @@ -211,6 +211,18 @@ "additionalProperties": false, "required": ["body"], "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Backend message id for this comment. Required when entity.kind is conversation on comment_* transitions (ADR 0086). GitHub: comment id or node_id; Slack: message ts." + }, + "parent_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Thread-root message id — always the safe reply target for this comment's thread (ADR 0086). Required when entity.kind is conversation on comment_* transitions. Equal to comment.id for top-level / thread-root messages; for replies, the root id (normalize GitHub discussion_comment null parent to id; Slack thread_ts, or ts when the message starts the thread)." + }, "command": { "type": "string", "maxLength": 64, @@ -346,7 +358,7 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Human-readable category name (GitHub Discussion category, Slack/Discord channel name mapped as category)." + "description": "Human-readable category name (GitHub Discussion category; Slack channel section when mapped)." }, "slug": { "type": "string", @@ -366,10 +378,10 @@ "description": "Optional portable category format for routing. Not a permission authority (ADR 0054). GitHub GraphQL DiscussionCategory exposes isAnswerable but no announcement/poll/discussion format field: adapters MUST set question_answer when isAnswerable is true, and SHOULD omit format otherwise unless they document an explicit heuristic (e.g. name/slug matching). UI-only GitHub create restrictions (e.g. announcement maintain/admin) are not queryable via the public API and MUST NOT be inferred solely from this field." } }, - "description": "Exclusive category of the conversation (1:M category→conversation). Distinct from state.labels (M:M tags on the conversation, not on messages)." + "description": "Exclusive category of the conversation (1:M category→conversation). Distinct from state.labels (M:M tags on the conversation, not on threads or messages)." } }, - "description": "Required when entity.kind is conversation and forbidden otherwise (ADR 0086). Snapshot of conversation-level metadata at event time, including when the transition is comment_added on a message. state.labels on conversation events are labels on the conversation; category is not a label." + "description": "Required when entity.kind is conversation and forbidden otherwise (ADR 0086). Snapshot of conversation metadata at event time. Threading is on transition.comment (id / parent_id), not here. state.labels are labels on the conversation; category is not a label; threads and messages are not labeled independently." }, "change_proposal": { "type": "object", diff --git a/internal/normevent/event.go b/internal/normevent/event.go index ef230f80ba..eb65a095fc 100644 --- a/internal/normevent/event.go +++ b/internal/normevent/event.go @@ -19,6 +19,8 @@ type Event struct { } // EntityKind identifies work items, change proposals, or conversations. +// Threads are not a separate entity kind; reply association uses +// transition.comment.parent_id (ADR 0086). type EntityKind string const ( @@ -75,8 +77,14 @@ type LabelChange struct { Action string `json:"action"` // added | removed } -// Comment describes a comment_added transition. +// Comment describes a comment_* transition. +// ID and ParentID are backend-native message ids (ADR 0086): GitHub comment +// id/node_id and normalized parent_id; Slack ts and thread_ts. +// For conversation comment events both are required; ParentID is always the +// thread-root id (equal to ID when this message is the root). type Comment struct { + ID string `json:"id,omitempty"` + ParentID string `json:"parent_id,omitempty"` Command string `json:"command,omitempty"` Body string `json:"body"` Instruction string `json:"instruction,omitempty"` @@ -246,6 +254,15 @@ func (e *Event) Validate() error { if strings.TrimSpace(e.State.Conversation.Category.Name) == "" { return fmt.Errorf("normalized event: state.conversation.category.name is required") } + switch e.Transition.Kind { + case TransitionCommentAdded, TransitionCommentEdited, TransitionCommentDeleted: + if e.Transition.Comment == nil || strings.TrimSpace(e.Transition.Comment.ID) == "" { + return fmt.Errorf("normalized event: transition.comment.id required for comment transitions on conversations") + } + if strings.TrimSpace(e.Transition.Comment.ParentID) == "" { + return fmt.Errorf("normalized event: transition.comment.parent_id required for comment transitions on conversations") + } + } } else if e.State.Conversation != nil { return fmt.Errorf("normalized event: state.conversation forbidden when entity.kind is %s", e.Entity.Kind) } diff --git a/internal/normevent/event_test.go b/internal/normevent/event_test.go index 42f0de4471..8f4b4e9e4d 100644 --- a/internal/normevent/event_test.go +++ b/internal/normevent/event_test.go @@ -85,12 +85,18 @@ func TestParseJSON_ConversationExample(t *testing.T) { require.NotNil(t, ev.State.Conversation) assert.Equal(t, "Vouch Request", ev.State.Conversation.Category.Name) assert.Equal(t, "vouch-request", ev.State.Conversation.Category.Slug) + require.NotNil(t, ev.Transition.Comment) + assert.Equal(t, "DC_kwDOExampleComment", ev.Transition.Comment.ID) + assert.Equal(t, "DC_kwDOExampleComment", ev.Transition.Comment.ParentID) m, err := ev.ToMap() require.NoError(t, err) conv := m["state"].(map[string]any)["conversation"].(map[string]any) cat := conv["category"].(map[string]any) assert.Equal(t, "vouch-request", cat["slug"]) + comment := m["transition"].(map[string]any)["comment"].(map[string]any) + assert.Equal(t, "DC_kwDOExampleComment", comment["id"]) + assert.Equal(t, "DC_kwDOExampleComment", comment["parent_id"]) } func TestValidate_ConversationRules(t *testing.T) { @@ -143,4 +149,52 @@ func TestValidate_ConversationRules(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "state.conversation forbidden") }) + + t.Run("comment_requires_id", func(t *testing.T) { + ev := base + ev.Transition = Transition{ + Kind: TransitionCommentAdded, + Comment: &Comment{Body: "/fs-vouch", ParentID: "DC_kwDO"}, + } + err := ev.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "transition.comment.id required") + }) + + t.Run("comment_requires_parent_id", func(t *testing.T) { + ev := base + ev.Transition = Transition{ + Kind: TransitionCommentAdded, + Comment: &Comment{ID: "DC_kwDOComment", Body: "/fs-vouch"}, + } + err := ev.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "transition.comment.parent_id required") + }) + + t.Run("comment_with_id_and_parent_ok", func(t *testing.T) { + ev := base + ev.Transition = Transition{ + Kind: TransitionCommentAdded, + Comment: &Comment{ + ID: "DC_kwDOComment", + ParentID: "DC_kwDOComment", + Body: "/fs-vouch", + }, + } + require.NoError(t, ev.Validate()) + }) + + t.Run("comment_reply_parent_ok", func(t *testing.T) { + ev := base + ev.Transition = Transition{ + Kind: TransitionCommentAdded, + Comment: &Comment{ + ID: "DC_kwDOReply", + ParentID: "DC_kwDOParent", + Body: "following up", + }, + } + require.NoError(t, ev.Validate()) + }) } From 86620b7710397379102014807a60aa9e95652f3d Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 20 Aug 2026 00:06:10 +0300 Subject: [PATCH 7/7] docs(adr): note non-numeric conversation entity id follow-on Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/ADRs/0086-conversation-surface-for-agent-participation.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/ADRs/0086-conversation-surface-for-agent-participation.md b/docs/ADRs/0086-conversation-surface-for-agent-participation.md index 51a1c14d8d..8d223a0cc3 100644 --- a/docs/ADRs/0086-conversation-surface-for-agent-participation.md +++ b/docs/ADRs/0086-conversation-surface-for-agent-participation.md @@ -154,6 +154,9 @@ enforceable from `format` alone. `transition.comment.id` / `parent_id`, and `state.labels`. - Slack maps channel→conversation, section→category; `parent_id` is always the reply target (Slack `thread_ts` / message `ts`; GitHub normalized parent). +- `entity.id` remains numeric for forge-native conversations (e.g. Discussion + number); non-numeric backends (Slack/Discord/Matrix) need a follow-on carrier + (e.g. opaque `entity.key` or string id) before those adapters. - Domain split: `forge.Client` / `tracker.Client` / `conversation.Client`. - Linking a conversation or thread to a work item for `/fs-code` remains a follow-on.