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_skillsand 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_ticketor the skill'sname) 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'sauthz/public_alloweddisallows, 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_allowedfor 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_ticketMUST 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-server —
create_ticketMUST re-validate the skill'sauthzblock 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_ticketMUST be idempotent ondraft_idwithin 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-liveddraft_id → issue IIDrecord 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_ITERATIONStool 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 withllm_chat,task_read,signal_read), enforced by the existingcapabilityGuardvia routedata.requiredCapabilityand sourced from the profile's MCP-initialize capabilities map like every other capability. Definingticket_createin 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
authzblock, 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/publicand the public URL isPOST /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-validatespublic_allowedper 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-gitlabis 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_fieldscoped labels + aSource::Beacon(orSource::Public) provenance label, optionalconfidentialper the skill'sconfidential_default. Target project from the skill'sgitlab.project, falling back toBEACON_GITLAB_DEFAULT_PROJECT. Thespec::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, lazyloadComponent→features/tickets/, guards[authGuard, profileGuard, capabilityGuard]withdata: { 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 viaupdate_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/ticketswith thedraft_idand 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-requestskills,chat_with_toolsprovider extension (Anthropic + OpenAI-compatible), Mode A local loop, GitLab httpx client, the three authenticated endpoints, frontend page with draft-review gate,ticket_createcapability. 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
- Humans attest. No issue is filed to GitLab without an explicit human Submit action carrying the confirmation flag. The LLM loop can only draft.
- Server-side authorization. Skill availability and filing rights are enforced in the backend from the caller's token; the frontend list is presentation only.
- One tool. Ticket creation is implemented once, in Beacon; Oracle dispatches it and never duplicates it.
- 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.
- Secret hygiene. The GitLab PAT never appears in logs, responses, audit records, or OTEL attributes.
- Anonymous is enumerated. The only unauthenticated path is the single Postern
write-allowlisted POST; anonymous callers see only
public_allowedskills and never get a conversational LLM loop. - Idempotent filing. A
draft_idfiles 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_allowedfiltering for anonymous. - Tool-calling:
chat_with_toolsrequest shaping and tool-call parsing per provider (Anthropic, OpenAI-compatible); bounded-loop termination atTICKET_CHAT_MAX_ITERATIONS; tools-incapable provider raises cleanly. - Authorization:
create_ticketrejects 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_idsubmitted 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_allowedskills 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 postsdraft_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