Skip to content

Beacon — Conversational Ticketing (GitLab issue creation)

Status & scope

Status: Proposed (2026-06-05). No code yet.

Package: beacon

Depends on: platform.axonis-core, platform.service-contract, platform.ingress-routing, component.beacon.workbench. Optional-mode dependencies: component.oracle.gateway and component.oracle.apollo (P2 layering only — the feature is fully functional without Oracle deployed) and component.postern.proxy (P3 public path only). The depends_on list in the frontmatter includes the optional-mode specs for traceability; they are NOT hard runtime dependencies.

Milestone: P1 (authenticated standalone), P2 (Oracle/Apollo layering), P3 (public path) — see §Milestones.

Out of scope: ticket triage and lifecycle after creation (assignment, state transitions, sync-back), GitLab webhooks, issue editing/closing, non-GitLab trackers, and any beacon-side rendering of existing GitLab issues.

Purpose

Users — analysts, operators, field/GTM staff — need a guided way to file well-formed GitLab issues (bug reports, operational requests such as "add a new sensor", feature requests) without knowing GitLab projects, scoped-label conventions, or issue templates. Today this knowledge lives in people's heads and in ad-hoc Claude skills (e.g. the spec-from-field skill used by product to turn field input into Functional/Dev specs).

Ticketing adds a Beacon page where the user converses with an LLM that runs a predefined skill per ticket type. The skill drives clarifying questions, gathers the type's required fields, and produces a structured draft (title, markdown body, labels, target project). The user reviews the draft and explicitly submits it; only then does Beacon's server-side GitLab tool file the issue.

The governing principle is platform Invariant 6 — AI assists, humans attest. The LLM drafts; the human files. Nothing reaches GitLab without an explicit human submit action.

Architecture

The GitLab issue-creation tool is implemented once, in Beacon's backend, and is the single source of truth for ticket creation in both deployment modes.

  • #REQ.gitlab-tool-in-beacon — the GitLab ticket-creation tool MUST be implemented in Beacon's backend and MUST function with no Oracle deployed.
  • #REQ.dual-mode-single-tool — there MUST be exactly one implementation of ticket creation (in Beacon). When Oracle is deployed it dispatches Beacon's tool; it never reimplements ticket logic.

Mode A — standalone (Beacon's own LLM)

Used when BEACON_LLM_* is configured (the existing optional-LLM pattern, component.beacon.workbench §LLM Capability). Beacon runs a bounded local tool-use loop.

Browser ── POST /api/v1/tickets/chat (SSE) ──► Beacon backend
                                                 ├─ load skill (bundled SKILL.md, server-gated)
                                                 ├─ tool-use loop over BEACON_LLM provider
                                                 │    drafting tools only (update_draft, ...)
                                                 └─ draft streamed back to the draft panel
Browser ── POST /api/v1/tickets (human Submit) ─► create_ticket ──► gitlab.axonis.ai REST v4

Mode B — Oracle deployed

When Beacon has no own LLM (or is configured to forward), ticket chat goes to the agentspace ingress like all other chat (component.beacon.workbench §Backend Routing); Oracle's ToolExecutor loop drives the conversation and dispatches Beacon's MCP-exposed ticket tools.

Browser ─► Beacon /api/v1/tickets/chat ─► agentspace.cluster.local ─► Oracle ToolExecutor
                                                                         │  MCP dispatch
                                                                         ▼
                                                              Beacon MCP tools
                                                              (list_ticket_skills,
                                                               update_ticket_draft)
Browser ─► Beacon /api/v1/tickets (human Submit) ─► create_ticket ─► gitlab.axonis.ai

Mode selection follows the existing convention: Beacon does not detect Oracle availability; it uses its own LLM when llm_spec.is_configured() (Mode A) and otherwise forwards chat to the agentspace ingress, which resolves to Oracle or Cortex (Mode B). In both modes the filing path is identical: the human Submit action calls Beacon's create_ticket.

  • #REQ.oracle-mcp-tool-exposure — Beacon MUST expose list_ticket_skills and the drafting tools on its MCP surface (server/mcp/server.py) so Oracle's dispatcher can route to them in Mode B. Authorization checks MUST produce identical results regardless of caller path (browser-direct vs Oracle MCP dispatch).

Relationship to workbench invariants

component.beacon.workbench Invariant 2 says Beacon does not implement domain logic. The GitLab tool is a documented exception: ticketing is workbench tooling about the platform (filing issues against the platform's own tracker), not platform domain logic — GitLab issues are not DES domain objects, carry no ABAC markings, and never enter the evidence chain. The exception is intentional and must not be used to justify moving domain intelligence into Beacon. Workbench Invariant 3 (Beacon never mints tokens) is unaffected: the GitLab PAT is a configured service credential, not a caller token.

Package structure

beacon/
  beacon/
    tickets/
      __init__.py
      skills.py           # SKILL.md loader + server-side gating filter
      skills/             # bundled skill definitions (one dir per ticket type)
        bug-report/SKILL.md
        sensor-request/SKILL.md
        feature-request/SKILL.md   # ported from the product `spec-from-field` Claude skill
      draft.py            # TicketDraft model + short-lived draft store (idempotency)
      gitlab.py           # GitLab REST v4 client (httpx)
      tool.py             # create_ticket / list_ticket_skills / update_ticket_draft
  server/
    api/
      tickets.py          # REST routes (see §Endpoints), wired into the /api/v1 app
    mcp/
      server.py           # ticket tools registered on Beacon's MCP surface (Mode B)
  frontend/
    src/app/features/tickets/   # page component, chat panel, draft panel, skill picker

The LLM provider layer (beacon/llm/) gains the tool-calling extension defined in §LLM tool-calling contract.

Skills

A skill defines one ticket type: how the LLM converses, which fields are required, and how the result maps to GitLab. Skill files are SKILL.md-compatible — the same markdown-plus-YAML-frontmatter shape as the Claude skills the team already writes — extended with a ticket: frontmatter block. Existing skills such as product's spec-from-field port in nearly as-is (its clarifying-question → Functional-Spec → Dev-Spec flow is the reference conversational pattern for the feature-request skill, and its output templates become the issue body).

---
name: feature-request            # stable ticket_type key (kebab-case)
description: >
  Use when someone says "a customer asked for X", "we need a feature that does Y",
  "turn this into requirements", ...
ticket:
  display_name: "Feature request"
  required_fields:
    - {key: summary, label: "One-line summary", type: string}
    - {key: problem, label: "Problem statement", type: text}
    - {key: severity, label: "Severity", type: enum, options: [low, medium, high, critical]}
  optional_fields:
    - {key: origin, label: "Where this came from", type: string}
  gitlab:
    project: "federated-ml-platform/<repo>"   # omitted → BEACON_GITLAB_DEFAULT_PROJECT
    labels: ["Type::Feature"]
    label_from_field: {severity: "Priority::{value}"}
    confidential_default: false
  authz:
    allowed_capabilities: [ticket_create]
    allowed_roles: []            # empty = any caller with the capability; server-checked
  public_allowed: false          # anonymous (Postern) eligibility
---
<markdown body: persona, clarifying questions, output template — the conversational
guidance the LLM follows; for feature-request this is the spec-from-field content>
  • #REQ.skill-local-fallback — Beacon MUST bundle its skill set and operate fully on the bundled skills when no Oracle/Apollo is present. Skills are loaded at startup; a malformed skill file is startup-fatal (never silently dropped).
  • #REQ.skill-apollo-layering — when Oracle is deployed, Apollo guidance artifacts (component.oracle.apollo; applicability service_name=beacon, tool_name=create_ticket or the skill's name) MAY augment a skill's conversational guidance and intent hints. Apollo MUST NOT widen authorization: it cannot grant a ticket type that the local skill's authz/public_allowed disallows, add labels outside the skill's GitLab mapping, or change the target project. The bundled skill is the security floor.
  • #REQ.skill-server-gating — the effective skill list returned to any caller MUST be filtered server-side against the caller's profile capabilities and roles (and public_allowed for anonymous callers). Frontend filtering is UX only.

Initial skill set: bug-report, sensor-request, feature-request.

Ticket creation tool

The tool surface (callable locally in Mode A, via MCP in Mode B):

Tool Mutating Purpose
list_ticket_skills no Effective (server-gated) skill list for the caller
update_ticket_draft no (draft only) LLM updates the structured draft from conversation
create_ticket yes File a confirmed draft to GitLab
  • #REQ.human-attest-before-file — the autonomous tool-use loop (Mode A local loop or Oracle's ToolExecutor) may invoke only the drafting tools. create_ticket MUST require a confirmation flag that is supplied exclusively by the human-driven Submit action (POST /api/v1/tickets); it MUST refuse when invoked from within an LLM loop or without the flag. The LLM can never auto-file.
  • #REQ.per-skill-authz-servercreate_ticket MUST re-validate the skill's authz block against the caller's token (capabilities, roles) at filing time, regardless of what the frontend or the LLM requested. A disallowed skill → 403, even if it appeared in a stale frontend list.
  • #REQ.tool-idempotency — every draft carries a client-generated draft_id. create_ticket MUST be idempotent on draft_id within a TTL window: a retry or double-submit returns the already-created issue IID instead of filing a duplicate. (GitLab has no native idempotency key; Beacon keeps a short-lived draft_id → issue IID record in its draft store.)
  • #REQ.ticket-audit — every filing (and every public submission, §Public submission) MUST be auditable: caller identity (or anonymous source), profile, skill, target project, resulting issue IID, timestamp. No secret or token ever appears in the audit record or logs.

LLM tool-calling contract

Beacon's LLM provider layer currently exposes streaming chat only (chat_stream(messages)) with no tool support. Mode A requires a minimal tool-calling extension:

  • #REQ.llm-tool-calling-contract — the provider base class MUST gain a tool-aware turn primitive:

python async def chat_with_tools( self, messages: list[dict], tools: list[ToolSpec], **kwargs ) -> ToolTurn # ToolSpec: name, description, JSON-schema input (provider-agnostic) # ToolTurn: assistant text + zero or more tool calls {id, name, arguments}

Providers normalize ToolSpec to their native shape — Anthropic tools/tool_use and the OpenAI-compatible tools/tool_calls shape (covers OpenAI, Groq, Ollama) are the minimum. A provider that cannot do tools MUST raise a clear "provider not tool-capable" error, never silently degrade.

  • The Mode A loop is bounded: at most TICKET_CHAT_MAX_ITERATIONS tool turns per user message, executing drafting tools against the local registry and feeding results back until the model produces a final response with no tool call.

Profile & role gating

  • #REQ.capability-gate — the page is gated by a new profile capability ticket_create (naming consistent with llm_chat, task_read, signal_read), enforced by the existing capabilityGuard via route data.requiredCapability and sourced from the profile's MCP-initialize capabilities map like every other capability. Defining ticket_create in the profile pack is a cross-repo dependency (profile packs are owned by the pack layer, not beacon — see product.profile / product.pack).
  • Per-ticket-type access is the skill's authz block, enforced server-side (#REQ.skill-server-gating, #REQ.per-skill-authz-server). The frontend hides disallowed skills for UX; the backend is the authority.
  • #REQ.anonymous-public-only — anonymous callers (Postern path) are offered and may submit ONLY skills with public_allowed: true, enforced server-side at both the skill listing and the submission endpoint.

Public submission via Postern

Anonymous users get a guided form, not a chat. The form is generated from the public-eligible skills' required_fields/optional_fields (the same schema that drives the LLM), and submits once.

Rationale: an anonymous multi-turn LLM chat behind the public edge is a token-cost amplifier and a prompt-injection target, and it contradicts Postern's posture as a thin allowlisted relay (component.postern.proxy). A single structured POST is trivially rate-limited and validated. The server MAY make at most one bounded LLM call to format the submitted text into a clean issue body; it MUST NOT hold a conversation with an anonymous caller.

  • #REQ.public-guided-form — the anonymous path is a single structured POST built from public skills' field schema; no anonymous multi-turn LLM chat exists.
  • #REQ.public-abuse-controls — the public endpoint MUST enforce a request body-size cap and per-field length caps, reject malformed/oversized requests with opaque errors, and rely on Postern's stricter per-source write rate limit upstream. A CAPTCHA/turnstile token check is a recommended deployment option (config-gated), not a hard requirement.
  • #REQ.public-postern-allowlist — public submission is reachable ONLY through Postern's enumerated POST allowlist. Postern allowlists in backend-path terms and never rewrites paths, so the entry is POST /api/v1/tickets/public and the public URL is POST /public-api/api/v1/tickets/public (the mirror-namespace contract, component.postern.proxy#routing-contract). Beacon's public endpoint accepts only the Postern-minted limited service token and re-validates public_allowed per skill; it is never exposed on an authenticated route table without that validation.

Public submissions are filed with a fixed provenance label (e.g. Source::Public) and the skill's confidential_default, and are fully audited (#REQ.ticket-audit) with the Postern-derived source identity.

Endpoints

Method Path Auth Purpose
GET /api/v1/tickets/skills caller Bearer token Effective skill list (server-gated)
POST /api/v1/tickets/chat caller Bearer token SSE drafting chat; Mode A local loop or Mode B forward
POST /api/v1/tickets caller Bearer token File a confirmed draft (attest gate, idempotent on draft_id)
GET /api/v1/tickets/public/skills Postern limited token Public-eligible skills + field schema (drives the form)
POST /api/v1/tickets/public Postern limited token One-shot anonymous guided-form submission

MCP surface (Mode B): list_ticket_skills, update_ticket_draft registered on Beacon's MCP server; create_ticket is intentionally NOT dispatchable from an LLM loop (#REQ.human-attest-before-file) — filing always goes through POST /api/v1/tickets.

GitLab integration

  • #REQ.gitlab-rest-v4-httpx — integration uses the GitLab REST v4 API via the existing httpx.AsyncClient. python-gitlab is explicitly rejected (synchronous, heavy dependency in an async service; Beacon already uses raw httpx for all upstream calls).
  • #REQ.gitlab-service-account — issues are created with a service-account PAT (BEACON_GITLAB_TOKEN). The token is a secret: never logged, never echoed to the client, never present in audit records or OTEL attributes.
  • #REQ.gitlab-label-mapping — issue creation maps the confirmed draft to GitLab fields: title (draft summary), description (markdown body produced from the skill's output template), labels = skill base labels + label_from_field scoped labels + a Source::Beacon (or Source::Public) provenance label, optional confidential per the skill's confidential_default. Target project from the skill's gitlab.project, falling back to BEACON_GITLAB_DEFAULT_PROJECT. The spec:: label namespace is never applied — it belongs to the spec-tracking projection, not to filed tickets.
  • GitLab errors (4xx/5xx) surface to the caller as a filing failure with the draft intact — the user can retry (idempotent) once the cause is fixed; failures are never swallowed.

Configuration

BEACON_GITLAB_URL              https://gitlab.axonis.ai     # GitLab base URL
BEACON_GITLAB_TOKEN            (secret)                     # service-account PAT
BEACON_GITLAB_DEFAULT_PROJECT  federated-ml-platform/...    # fallback target project
TICKET_CHAT_MAX_ITERATIONS     6                            # Mode A tool-loop bound
TICKET_DRAFT_TTL_S             86400                        # draft store / idempotency window
TICKET_PUBLIC_MAX_BODY_KB      64                           # public submission size cap
TICKET_PUBLIC_CAPTCHA          (optional)                   # turnstile/captcha verification key

BEACON_LLM_* (existing, component.beacon.workbench §LLM Capability) selects Mode A when configured. All config read through the service's single settings reader per platform.service-configuration conventions.

Frontend page

  • Route tickets, lazy loadComponentfeatures/tickets/, guards [authGuard, profileGuard, capabilityGuard] with data: { requiredCapability: 'ticket_create' }.
  • Two-panel layout: left — chat panel (reuses the existing SSE chat plumbing, pointed at /api/v1/tickets/chat); right — live draft panel rendering the structured draft (title, markdown body preview, target project, labels, confidential flag) as the LLM updates it via update_ticket_draft.
  • Skill picker on entry, sourced from GET /api/v1/tickets/skills (server-gated list).
  • #REQ.frontend-draft-review — filing requires an explicit human "Submit ticket" action on the draft panel; the UI MUST NOT auto-submit under any condition. Submit calls POST /api/v1/tickets with the draft_id and confirmation flag; success renders a link to the created GitLab issue.

The public guided form (P3) is a separate minimal surface rendered from GET /api/v1/tickets/public/skills field schema — no chat panel, no draft loop.

Milestones

  • P1 — Authenticated standalone. Skill loader + bundled bug-report / sensor-request / feature-request skills, chat_with_tools provider extension (Anthropic + OpenAI-compatible), Mode A local loop, GitLab httpx client, the three authenticated endpoints, frontend page with draft-review gate, ticket_create capability. Fully usable with no Oracle and no Postern.
  • P2 — Oracle/Apollo layering. Ticket tools on Beacon's MCP surface, Mode B forwarding via agentspace, Apollo artifact augmentation of skill guidance. Additive; no new user-facing surface.
  • P3 — Public path. component.postern.proxy write-allowlist amendment shipped, /api/v1/tickets/public(/skills) endpoints, anonymous guided form, abuse controls, public audit.

Invariants

  1. Humans attest. No issue is filed to GitLab without an explicit human Submit action carrying the confirmation flag. The LLM loop can only draft.
  2. Server-side authorization. Skill availability and filing rights are enforced in the backend from the caller's token; the frontend list is presentation only.
  3. One tool. Ticket creation is implemented once, in Beacon; Oracle dispatches it and never duplicates it.
  4. Local security floor. Bundled skill definitions are the ceiling on what any layered guidance (Apollo) can enable; layering can refine conversation, never widen access, labels, or targets.
  5. Secret hygiene. The GitLab PAT never appears in logs, responses, audit records, or OTEL attributes.
  6. Anonymous is enumerated. The only unauthenticated path is the single Postern write-allowlisted POST; anonymous callers see only public_allowed skills and never get a conversational LLM loop.
  7. Idempotent filing. A draft_id files at most one issue within the TTL window.

Test expectations

  • Skill loader: bundled SKILL.md files parse; a malformed skill is startup-fatal; effective-list filtering by capability/role; public_allowed filtering for anonymous.
  • Tool-calling: chat_with_tools request shaping and tool-call parsing per provider (Anthropic, OpenAI-compatible); bounded-loop termination at TICKET_CHAT_MAX_ITERATIONS; tools-incapable provider raises cleanly.
  • Authorization: create_ticket rejects a disallowed skill even when the request claims it (server-side gate); identical authz outcome browser-direct vs MCP-dispatched.
  • Attest gate: the autonomous loop never invokes create_ticket; filing without the confirmation flag is refused.
  • Idempotency: same draft_id submitted twice → one GitLab issue; second returns the existing IID.
  • GitLab client: mocked REST v4 — title/description/labels (base + field-mapped + provenance) and confidential flag correct; PAT never logged; upstream 4xx/5xx surfaces as a retryable filing failure.
  • Dual mode: Mode A end-to-end with mock provider + mock GitLab; Mode B forwarding shape (mock agentspace) and MCP tool registration.
  • Public path: only public_allowed skills accepted; body-size and field-length caps (413/400); no LLM loop reachable anonymously; Postern-token-only access.
  • Frontend (Vitest): capability guard hides the page without ticket_create; draft panel requires explicit Submit; submit posts draft_id + confirmation; success renders the issue link.
  • Audit: authenticated filing and public submission both produce complete audit records containing no secrets.

Depends on: component.beacon.workbench, component.oracle.apollo, component.oracle.gateway, component.postern.proxy, platform.axonis-core, platform.ingress-routing, platform.service-contract