Vancetope — Fook Upstream Transport
Locally triaged Fook tickets are forwarded to an external ticket system (Default: GitHub Issues). Vancetope thus acts as the collector/preparation pipeline, while the target system is the single source of truth — Frankie and the Maintainers work there, not in Vancetope. Reporters receive a link in their Inbox and track the ticket in the external tracker; status updates and Maintainer comments are polled back and also appear as Inbox items.
v1 has exactly one adapter: GitHub Issues. The interface {@code TicketProvider} is provider-agnostic; further adapters (GitLab, Gitea, Jira, …) can be added as additional Spring Beans.
See also: fook-service | user-interaction | setting-forms
1. Purpose & Scope
Problem. Fook produces locally triaged tickets. For Maintainers to see them and Frankie to process them, they must be sent to a central ticket system — specifically:
- anonymized (no user/Tenant leak),
- with approval mode (no accidental sending),
- bidirectional (status + comments flow back),
- multi-pod safe (only one Pod sends per cluster).
Solution. A small transport pipeline with three components:
- {@code TicketProvider} — Adapter interface to the target system. v1 implementation: {@code GitHubTicketProvider} via the REST API v3.
- {@code FookTicketAnonymizer} — Pure-logic component that hashes reporter identity and scrubs free text using configurable regex patterns before anything leaves the Tenant boundary.
- {@code FookUpstreamService} — Cluster-Master-Pod-gated scheduler with two ticks: Sender (local → Provider) and Poll (Provider → local).
What it is not:
- Not a dedicated Vancetope-HQ-Brain — the goal is a real ticket system (GitHub Issues); Vancetope remains a collector. Frankie operates exclusively against the target system, not against Vancetope’s internal storage.
- No federation, no multi-hop, no custom webhook — Vancetope pulls.
- No data recycling guarantee back to Vancetope — closed tickets can be deleted/restructured in GitHub without Vancetope noticing. This is accepted.
2. Mode Enum
Setting: {@code fook.upstream.mode}, Default {@code never}.
| Value | Behavior |
|---|---|
| {@code never} | Nothing is sent. Sender tick and Poll tick run but return immediately. Inbox item during triage says “stays local”. Day-1 default. |
| {@code automatic} | Every ticket triaged as {@code new_ticket} gets {@code transportApproval=auto} and is pushed by the next Sender tick. |
| {@code manual} | Triage sets {@code transportApproval=pending}. Only when an Admin sets {@code approved} (Admin UI or direct DB) does the tick send. |
Changing the mode only affects future tickets — tickets already
tagged with none will not suddenly be sent if the mode later
switches to automatic.
3. Sender Tick — {@code @Scheduled}
@Scheduled(fixedDelayString="${vance.fook.upstream.sendTick:PT5M}")
public void sendTick() {
if (!masterService.isLocalPodMaster()) return;
if ("never".equals(currentMode())) return;
TicketProvider provider = currentProvider();
if (provider == null) return;
for (TicketDocument ticket : ticketService.listPendingTransport()) {
try {
ProviderTicketDraft draft = anonymizer.buildDraft(ticket, ...);
ProviderTicketRef ref = provider.create(draft);
ticketService.markTransferred(ticket.id,
provider.name(), ref.externalId, ref.url);
inboxItemService.updateContent(
reporterTenant, ticket.inboxItemId,
"Ticket transferred", body, payload, "fook");
} catch (ProviderException e) {
if (e.isRetryable()) {
// log + retry next tick
} else {
ticketService.markTransferFailed(...);
inboxItemService.updateContent(... failure body ...);
}
}
}
}
Important — Multi-Pod Guard. {@code ClusterMasterService.isLocalPodMaster} is the first check. Follower Pods return immediately and don’t even read settings. This is the same pattern as {@code ClusterDistributorTick} and {@code ClusterCleanupTick}. If the Master crashes, another Pod takes over after lease expiry.
Pending List. {@code FookTicketService.listPendingTransport()} filters tickets in the local store for {@code status=new} AND {@code transportApproval ∈ {auto, approved}}.
Retryable vs. permanent failure. Providers throw {@code ProviderException} with an {@code isRetryable()}-flag:
- {@code 429}, {@code 5xx}, Network-Error → retryable. Ticket remains in {@code new}; the next tick tries again. No Inbox update.
- {@code 4xx} except 429 → permanent. Ticket changes to {@code status=failed}; the Inbox item is updated to “Transfer failed”; an Admin must intervene.
4. Poll Tick — Status + Comments Back
@Scheduled(fixedDelayString="${vance.fook.upstream.pollTick:PT1H}")
public void pollTick() {
if (!masterService.isLocalPodMaster()) return;
if ("never".equals(currentMode())) return;
if (!pollEnabled()) return;
TicketProvider provider = currentProvider();
List<TicketDocument> mine = ticketService.listTransferredForPolling()
.stream()
.filter(t -> provider.name().equals(t.upstreamProvider))
.toList();
Instant since = oldestLastSyncedAt(mine).orElse(EPOCH);
List<ProviderTicketUpdate> updates = provider.pollUpdates(refs, since);
for (ProviderTicketUpdate u : updates) {
TicketDocument local = lookup(u.ref.externalId);
if (u.state != null && !u.state.equals(local.upstreamState)) {
ticketService.markUpstreamState(local.id, u.state);
// Status-Inbox-Item OUTPUT_TEXT, criticality LOW
}
for (ProviderComment c : u.newComments) {
// Comment-Inbox-Item FEEDBACK, criticality NORMAL,
// requiresAction=true
}
}
}
Polling Anchor. We use the minimum of {@code upstreamLastSyncedAt}
of all transferred tickets as the since-anchor — no update will be
missed, even with arbitrarily long Pod downtimes. For the first poll,
it falls back to {@code transferredAt}.
Provider Filter. Tickets that were once transferred by Provider A (e.g., GitHub) are only polled by Provider A — even if the Brain has since switched to GitLab, the old tickets remain in their original connection.
Inbox items for status changes use {@code InboxItemType.OUTPUT_TEXT}
- {@code Criticality.LOW} + {@code tags=[fook, fook-status]} + {@code requiresAction=false}. They inform without forcing an action.
Inbox items for new comments use {@code InboxItemType.FEEDBACK}
- {@code Criticality.NORMAL} + {@code tags=[fook, fook-comment]} + {@code requiresAction=true}. Reporter clicks “Reply” → Inbox reply handler calls {@code provider.postComment(ref, replyText)} (see §7).
5. Anonymization Pipeline
FookTicketAnonymizer.buildDraft(ticket, instanceSecret,
instanceFingerprint, anonymize, scrubPatterns, extraLabels):
5.1 Identity Hashing
Reporter identity is replaced by a 16-hex-char hash:
reporterHash = sha256(tenantId + "|" + userId + "|" + instanceSecret)[:16]
- Deterministic across Brain restarts (even for the same reporter → same hash). Frankie can recognize “same reporter, three reports” without ever seeing the real name.
- Salted (Brain-Instance-Secret) — other Vancetope instances produce different hashes for the same reporter. Cross-instance correlation is explicitly not possible, privacy by design.
If {@code anonymize=false}: everything as literal {@code “anonymous”} — maximum privacy, but no correlation.
5.2 Content Scrubbing
Four regex patterns are built-in, each optionally activatable:
| Pattern | Regex (simplified) | Replacement |
|---|---|---|
| {@code email} | [\w.+-]+@[\w.-]+\.\w{2,} |
{@code [redacted-email]} |
| {@code ipv4} | \d{1,3}(\.\d{1,3}){3} |
{@code [redacted-ip]} |
| {@code apiKey} | sk-…, ghp_…, xox[bpa]-… (Anthropic, OpenAI, GitHub, Slack) |
{@code [redacted-key]} |
| {@code guid} | [0-9a-f]{8}-…-[0-9a-f]{12} |
{@code [redacted-uuid]} |
The operator selects via setting {@code fook.upstream.scrub.patterns} (comma-separated). Default: all four enabled. Unknown pattern names are ignored (with warning); known ones apply in the specified order.
Application: title, description, triageNote. Not: structured metadata (type/severity/timestamps).
5.3 Bilingual Body (Language)
Before sending, another detail applies: the target system (GitHub Issues) is read by a Maintainer community that primarily works in English. Reporters, however, may write in their native language.
The Triage LLM provides both versions
(see fook-service.md §6.1):
derivedTitle→ always EnglishenglishTranslation→ English translation of the description, or empty string if the original was already English
FookService.bilingualDescription(original, englishTranslation)
assembles the final description as:
<englishTranslation>
--- Original:
<original report>
— if englishTranslation is non-empty. Otherwise, only the
original remains. Reporters see the bilingual body (English
on top) in their Inbox; Maintainers on GitHub see the same. The original
audit trail is preserved — the LLM can sometimes translate incorrectly.
Anonymization applies after translation, i.e., scrubber patterns run over the final bilingual text — email addresses and tokens are redacted in both languages.
5.4 What is not sent
| Field | Treatment |
|---|---|
| {@code reporter.userId} | → reporterHash |
| {@code reporter.tenantId} | → instanceFingerprint |
| {@code context.projectId/sessionId/processId} | strip — Tenant-internal |
| {@code context.recipe/engine} | remains — Vancetope constants, helpful |
| {@code id} (Fook-UUID) | remains — as {@code fookTicketId} in the Body-Footer for reverse lookup |
6. GitHub Convention
The Vancetope-flavored GitHub Convention is defined by the {@code GitHubTicketProvider}. Other Providers must align with it.
6.1 Issue Title
[fook] <derivedTitle>
The prefix makes Maintainer filtering trivial — Issues search via {@code label:fook} or directly in the title filter.
6.2 Issue Body
> Auto-generated by Vancetope Fook from a user/engine support request.
> Source: `vance-7f3a2c1d` · Reporter: `b2f1a3c4d5e6f789`
## What was reported
<scrubbed description>
## Triage notes
<scrubbed triageNote, optional>
## Metadata
- type: bug
- severity: high
- recipe: arthur
- engine: arthur
- triagedAt: 2026-06-09T16:00:00Z
- fookTicketId: `7e3f1c2a-...`
6.3 Labels (automatic)
Every Issue carries:
- {@code fook} (filter anchor)
- {@code fook/
} (`fook/bug`, `fook/feature`, `fook/question`, `fook/other`) - For {@code type=bug}: {@code fook/severity-
} ({@code low}/{@code medium}/{@code high}) - Plus {@code fook.upstream.github.extraLabels} (Tenant-configurable)
6.4 Comment Body (Reporter Reply)
> Reply from reporter `<reporterHash>` via Vancetope Fook bridge.
<scrubbed reply text>
Reporters do not need a GitHub account — the Vancetope Bot posts on behalf of the bridge. Maintainers see the hash and can reply back via the bridge if needed.
6.5 Auth
Personal Access Token (fine-grained) with {@code issues:write} and {@code metadata:read} on the target repo. Recommended: dedicated Bot account {@code vance-fook-bot} instead of Maintainer PAT. Token is stored as a {@code PASSWORD}-Setting (encrypted at-rest).
6.6 API Base URL for Enterprise
Default {@code https://api.github.com}. Operator overrides {@code fook.upstream.github.apiBase} for GitHub Enterprise Server (e.g., {@code https://github.acme.de/api/v3}).
7. Comment Reply Bridge
When a Reporter receives a FEEDBACK Inbox item and clicks “Reply”, the reply must go back to the target system.
v1 implementation: existing Inbox reply handler calls a new method {@code FookUpstreamService.postReply(inboxItem, replyText)}, which in turn calls {@code TicketProvider.postComment(ref, scrubbedText)}. (Anonymization also applies here — reply text is scrubbed by {@code FookTicketAnonymizer.scrubText}, then the body template is used with a hash prefix.)
The Inbox item payload already carries everything needed: {@code upstreamProvider}, {@code upstreamUrl}, {@code commentExternalId} (for threading), {@code ticketId}.
Implementation note: Reply wiring is a task for Phase 2 — the v1 scope builds the receiving side + the FEEDBACK Inbox item. Reply routing can be integrated with the already existing Inbox answer handlers.
8. Settings — Complete
All settings at the Brain-Instance-Scope ({@code tenant=_vance, scope=project, refId=_tenant}) — manageable via Setting Form {@code _vance/setting_forms/fook-upstream.yaml}.
fook.upstream.mode: never # never | automatic | manual
fook.upstream.providerType: github # v1: only github
fook.upstream.github.owner: "" # GH org/user
fook.upstream.github.repo: "" # GH repo
fook.upstream.github.token: "" # PASSWORD (encrypted)
fook.upstream.github.apiBase: https://api.github.com
fook.upstream.github.extraLabels: "" # comma-csv
fook.upstream.anonymize: true # default on
fook.upstream.scrub.patterns: email,ipv4,apiKey,guid
fook.upstream.statusPoll.enabled: true
fook.upstream.statusPoll.interval: PT1H # via vance.fook.upstream.pollTick
# (Spring-prop, not setting-form)
fook.upstream.instanceFingerprint: "" # auto-generated on first send
fook.upstream.instanceSecret: "" # auto-generated on first send
Read path is always {@code SettingService.getStringValueCascade( tenant=_vance, projectId=null, processId=null, key)} — lands on {@code (SCOPE_PROJECT, _tenant)}, where the Setting Form writes.
Auto-Generate on first send (via
{@code SettingService.set(…)}): fingerprint = vance-<8hex>,
secret = random UUID. Remain stable across restarts.
9. Ticket Schema Extension
Ticket-{@code $meta} is extended with these scalars (existing fields from {@code fook-service.md} §7 remain):
$meta:
... # unchanged
status: new | transferred | failed # local transport lifecycle
transportApproval: auto | pending | approved | none
inboxItemId: <inbox-id> # back-pointer for in-place update
transferredAt: <ISO> # set after successful send
upstreamProvider: github
upstreamExternalId: "4287"
upstreamUrl: "https://github.com/mhus/vance/issues/4287"
upstreamState: open | closed # mirror
upstreamLastSyncedAt: <ISO> # poll-tick anchor
All fields are optional ({@code null} on non-transferred tickets).
10. Cluster Master Pod
Both ticks are master-only:
if (!masterService.isLocalPodMaster()) return;
Exactly one Pod in the cluster sends/polls. Other Pods run the same Spring scheduler but return immediately. In case of Master failover (lease expiry), another Pod takes over seamlessly without configuration changes.
Why this is so important: without a guard, every Pod would push every pending ticket → N-fold GitHub Issues, N-fold GH API rate limit waste, N-fold Inbox items per status change. The lock is not optional, but mandatory security.
11. Inbox Items
11.1 Tracker Item (attached to the ticket)
Written by {@code FookService} directly after triage. Updated by {@code FookUpstreamService} on transfer success/failure in-place via {@code InboxItemService.updateContent}.
title: "Ticket created" → "Ticket transferred" / "Ticket transfer failed"
type: OUTPUT_TEXT
criticality: LOW
requiresAction: false
tags: ["fook"]
body: Text as below
payload: { decision, ticketId, status, upstreamProvider?,
upstreamExternalId?, upstreamUrl? }
| Phase | Body |
|---|---|
| Triage (mode=automatic) | “Your submission was opened as ticket <uuid>. It is being forwarded to the upstream ticket system; this item updates with the link once the transfer completes.” |
| Triage (mode=manual) | “… waiting for an admin to approve forwarding …” |
| Triage (mode=never) | “… stays local — upstream forwarding is disabled on this brain.” |
| Transfer-Success | “Your submission was transferred to the upstream ticket system. Track it at <URL>” |
| Transfer-Failure | “Forwarding your submission to the upstream ticket system failed permanently. Reason: <msg>. Contact an admin to retry.” |
11.2 Status Update Item
A new item per upstream status change (not in-place):
title: "Ticket status: open" / "Ticket status: closed"
type: OUTPUT_TEXT
criticality: LOW
requiresAction: false
tags: ["fook", "fook-status"]
11.3 Comment Item
A new item per new Maintainer comment:
title: "New comment from <author>"
type: FEEDBACK
criticality: NORMAL
requiresAction: true
tags: ["fook", "fook-comment"]
payload: { ticketId, upstreamProvider, upstreamUrl,
commentExternalId, author }
Reporter clicks “Reply” → Reply goes back to the Provider via {@code postComment} (see §7).
12. Failure Modes
| Failure | Treatment |
|---|---|
| {@code _vance} lacks LLM credentials (Triage) | Fallback to Reporter Tenant — see fook-service.md §5 |
| Provider HTTP 429 / 5xx | Retryable — Ticket remains {@code new}, next tick |
| Provider HTTP 4xx (except 429) | Permanent — {@code status=failed}, Inbox item to “transfer failed” |
| Provider down during Poll | Log + skip, next tick tries again |
| {@code _vance} has no {@code instanceFingerprint} | Auto-generate + persist on first send |
| Master Pod Failover | New Master takes over seamlessly — no duplicate sends due to lease guarantee |
| Reporter gone (User/Tenant deleted) | Inbox write fails → Log, no retry |
| Setting Form empty + mode != never | Provider checkConnection() shows red, transferOne throws permanent + Inbox failure |
13. Observability
Metric Convention (Micrometer, see CLAUDE.md):
- {@code vance.fook.upstream.transfers} with tag {@code outcome ∈ {success, retry, failed}}
- {@code vance.fook.upstream.polls} with tag {@code outcome ∈ {success, error}}
- {@code vance.fook.upstream.comments_in} (incoming comments from Provider)
- {@code vance.fook.upstream.comments_out} (Reporter replies to Provider)
- {@code vance.fook.upstream.queue.pending} (Gauge, current pending count)
- {@code vance.fook.upstream.queue.transferred} (Gauge)
No high-cardinality tags ({@code reporterHash}, {@code ticketId} NEVER).
Audit trail via existing {@code AuditService} for {@code SettingService.set(…)} for token updates.
14. Out of Scope (v2+)
- Webhook instead of Polling — if GH Issue webhooks are configured for the Brain’s public URL, pollTick can be replaced by push reception. v2.
- Federation / Multi-Sink — one Brain, multiple sinks simultaneously (e.g., GitHub for Bugs, Jira for Features). v3+.
- Pre-Send User-Approval-UI — currently, the Admin writes {@code transportApproval=approved} directly; a dedicated “Pending Tickets” Admin Dashboard would be v2.
- OAuth-linked Reporter Identity — Reporter logs in once with a GH account, replies appear under their GitHub identity instead of the Bot. v3.
- LLM-based Scrubber — Regex doesn’t catch everything (no “My boss Max Mustermann said …”). v2 with a small LightLlm pass.
- Bulk Export — import existing tickets from a second Brain. Out of scope.
15. References
- fook-service — the local triage pipeline, from which data is further processed here.
- user-interaction — Inbox subsystem.
- setting-forms — Setting Form format.
- light-llm-service — used by the triage path; transport itself communicates directly with the Provider.
- {@code de.mhus.vance.brain.cluster.ClusterMasterService} — the Master lease, to which the ticks are tied.