Skip to content

fix(web): keep synthetic fingerprints out of the errors sparkline query - #573

Merged
JeremyFunk merged 1 commit into
mainfrom
fix/errors-spark-synthetic-fingerprints
Aug 23, 2026
Merged

JeremyFunk merged 1 commit into
mainfrom
fix/errors-spark-synthetic-fingerprints

Conversation

@JeremyFunk

@JeremyFunk JeremyFunk commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator

What broke

Loading /errors returned a 500 from errorsSpark:

Cannot parse string 'alert:550bb2c5-…:scraper' as UInt64: syntax error at begin of string

error_issues.fingerprint_hash is shared across three issue kinds:

kind fingerprint
error decimal UInt64 string from ClickHouse
alert alert:{ruleId}:{groupKey}
integration planetscale:{database}:{event}

The errors hub sent every visible issue's fingerprint to the batched sparkline
query, which lowers the list into FingerprintHash IN (toUInt64('…'), …). A
single alert-backed issue on screen made ClickHouse reject the whole batch — so
the sparklines failed for every row in the list, not just that one.

Issue detail never hit this because ErrorIssueReadModelsService.getIssue
already skips the warehouse for non-error kinds.

Fix

Apply the same guard before the round-trip, as sparkFingerprintHashes() in
lib/models/error-signal.ts. Alert and integration rows already degrade
correctly: buildErrorSignals falls back to an empty series, and
getErrorsSpark short-circuits an empty list, so a view holding only alert
issues skips the request altogether.

Not covered here

/internal/query-engine/errors-spark still lowers a caller-supplied list
straight into toUInt64(), so a future caller passing a synthetic key gets a
500 rather than a 400. Tightening the shared FingerprintHash primitive to
decimal-only would be wrong — parseFingerprintHashes in the v2 issues route
legitimately filters Postgres by alert: values — so that hardening would need
to live at the warehouse-bound boundary specifically. Left out of this fix.

Verification

  • bun run --cwd apps/web test src/lib/models/error-signal.test.ts — 12 passed,
    including a new case built from the fingerprint in the failing trace
  • bun run --cwd apps/web typecheck — clean

Not verified in a browser: reproducing needs an alert-backed issue in the hub
list, which local data does not have. The failing path is a data-selection
choice, covered by the unit test instead.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

`error_issues.fingerprint_hash` is shared by three issue kinds: "error" rows
hold the decimal UInt64 from ClickHouse, while "alert" and "integration" rows
reuse the column for a synthetic key (`alert:{ruleId}:{groupKey}`,
`planetscale:{database}:{event}`).

The errors hub sent every visible issue's fingerprint to `errorsSpark`, which
lowers the list into `FingerprintHash IN (toUInt64('…'), …)`. One alert-backed
issue on screen made ClickHouse reject the whole batch, so the sparklines 500'd
for every row in the list.

Filter to error-kind issues before the round-trip — the same guard
`ErrorIssueReadModelsService.getIssue` already applies on the detail path, which
is why issue detail never hit this. Rows without a series already degrade to an
empty spark, and an all-alert list skips the request entirely.
@JeremyFunk
JeremyFunk merged commit 4f82bd2 into main Aug 23, 2026
31 checks passed
@JeremyFunk
JeremyFunk deleted the fix/errors-spark-synthetic-fingerprints branch August 23, 2026 21:18
@github-actions

Copy link
Copy Markdown
Contributor

🍁 Maple PR preview

Warning

Preview cleanup could not be confirmed. The Alchemy teardown outcome was skipped.

Final commit bb31699 · View workflow run

Makisuo added a commit that referenced this pull request Aug 24, 2026
`error_issues.fingerprint_hash` is shared by three issue kinds: "error" rows
hold the decimal UInt64 from ClickHouse, while "alert" and "integration" rows
reuse the column for a synthetic key (`alert:{ruleId}:{groupKey}`,
`planetscale:{database}:{event}`). `toUInt64('alert:…')` does not skip that
value, it aborts the whole query — so one alert-backed issue in a batch of
twenty 500s the request.

#573 filtered the list in the errors hub, which fixed the surface that was
firing. This is the same guard on the side no other caller can bypass:
`fingerprintHashIn` drops what the warehouse could not hold, which is exact
rather than lenient — a synthetic key cannot have error events by construction,
so nothing a caller could have matched is lost. An all-synthetic list emits a
false literal, because `IN ()` is a ClickHouse syntax error.
Makisuo added a commit that referenced this pull request Aug 24, 2026
* feat(errors): attach PRs to issues and verify the fix after merge

An error issue could carry a fix proposal but never a fix. `propose_fix`
accepted a `pr_url` as free text on an event payload — nothing resolved it,
nothing watched it, and nothing confirmed the fix worked, so issues sat in
`in_review` until someone remembered them.

A pull request is now a durable link on an issue. When it merges, the issue
enters a new `verifying` state with a quiet window sized from its severity and
its own pre-merge occurrence rate. When the window closes, a verification agent
runs and either closes the issue (low/medium severity) or posts a verdict for a
human (high/critical).

Two pieces of existing machinery made this cheap:

- The GitHub App integration already had a signature-verified webhook receiver
  and a sync queue. `pull_request` was the one event class it ignored.
- `isRegression` already decides whether an occurrence is a real regression by
  asking whether its build was in the set seen at resolution time. That is
  exactly the "did the fix work" predicate — a merge just needs the same
  snapshot taken at merge instead of at close. So the verdict rests on a
  deterministic signal the agent corroborates rather than one it invents.

Notable behaviour:

- If the error fires from a post-merge build while the window is still open,
  the fix demonstrably failed and the error tick settles it immediately, with
  no agent pass. Occurrences from builds that predate the merge are discounted
  as old clients still in the wild.
- Verification reads an absent build signal as no evidence, unlike
  `isRegression` which errs toward reopening. Erring the other way would refute
  every fix arriving from a service that does not report `service.version`.
- An investigation reaching `diagnosed` means the agent found a live cause, so
  for a verification that maps to "not fixed"; `inconclusive` corroborates the
  fix. `apply-diagnosis` is now subject-aware so a verification's
  `severityAssessment` cannot silently re-rank an issue a human already
  triaged.
- Three paths create the same link (propose_fix, the UI, the webhook's body
  scan), so linking is idempotent. The body scan only matches a dashboard issue
  URL or a `maple-issue:<uuid>` token, and confirms the issue belongs to the
  webhook's org first — a PR body is attacker-influenced text. `Fixes #123` is
  deliberately unsupported: Maple issue ids are UUIDs, so a bare integer can
  only name the host provider's own tracker.
- VCS stays ignorant of issues. The webhook reaches the errors side through a
  `PullRequestEventSink` port, bound in the composition roots, which also lets
  the sync-service tests assert forwarding without the error-issue stack.

Requires a manual change outside this repo: the GitHub App must add the
`Pull request` webhook subscription, or no delivery ever arrives.

* refactor(domain): parse URLs with Schema.URLFromString, not try/catch

`parsePullRequestUrl` and `extractIssueIdsFromText` wrapped `new URL(...)` in
`try/catch` to turn a thrown `TypeError` back into a value. Effect has the
primitive for this: `Schema.URLFromString`, decoded through
`Schema.decodeUnknownOption`, reports the failure as an `Option` instead.

Effect's own `SchemaTransformation.urlFromString` is what backs it, so this is
the same check the schema layer already performs everywhere else rather than a
second, hand-rolled one.

Both functions stay synchronous and total, which is what lets the webhook path,
the HTTP handler, and the tests keep calling them as plain predicates.

* fix(errors): stop the verification tick auto-closing on an interrupt

Review of the fix-verification feature turned up one wrong failure type and
a cluster of ways the tick could close an issue it should not have.

- `linkPullRequest` reported a failed upsert/hydrate as
  `ErrorIssuePullRequestInvalidError`. The URL had already parsed, so this
  told the caller their valid URL was unrecognizable and — because that tag
  is an anticipated error — suppressed the span exception, hiding a
  persistence anomaly from error dashboards. Both paths now fail with
  `ErrorPersistenceError`, which was already in the endpoint's union.
- `Effect.catchCause` catches interrupts too, so an isolate teardown during
  the enqueue became `{ enqueued: false }`, which the fallback reads as "no
  agent available" and answers with a terminal `verified` verdict. All three
  catches now re-raise interrupt-only causes, matching every sibling tick.
- A row whose verdict write failed deterministically aborted the tick before
  phase 2 every minute, freezing every other issue in `verifying` with
  all-zero counters. Verdict writes are guarded per row and report a new
  `failedRows` count.
- The two branches that apply a real verdict counted themselves as `skipped`,
  so `verdicts_applied` under-reported exactly the auto-closing paths.
- A per-issue persistence failure aborted the rest of a multi-issue webhook,
  which both docstrings promise it does not.
- `String(cause)` in log annotations dumped whole nested causes; use
  `summarizeCause` as the sibling enqueue already does.
- One live verification per issue rested on a read-then-insert while the
  queue consumer runs with `maxConcurrency: 2`. The partial index is now
  UNIQUE (folded into this branch's own migration) and the insert tolerates
  the conflict.
- Annotate the four new HTTP handlers and the enqueue's three unannotated
  exits, including `no_binding` — the outcome that can auto-close.

UI: the waiting card read "Waiting until in 6h"; link failures all reported
as a bad URL; the attach dialog cleared the pasted URL before the mutation
resolved; the rail cards had no inset; a fix-verification investigation
reached chat keyed by the investigation id rather than the issue's.

Tests: pin the deliberately inverted verdict mapping and the enqueue's four
outcomes, assert PR-event forwarding through the sink (the harness existed
but nothing read it), assert link failures by tag and add cross-tenant
cases. Two existing tests asserted nothing: the machine-owned-state guard
filtered by the same set the implementation uses, and a fixture UUID had a
version nibble `Schema.isUUID()` rejects, so the multi-issue extraction it
claimed was unreachable.

* fix(ci): mirror link_pull_request into the Slack approval canary, regen iOS spec

Both were already red on the parent commit — this branch added a mutating MCP
tool and a workflow state without updating the two places that mirror them.

`link_pull_request` reached `mutating.ts` and slack-agent's
`MUTATING_TOOL_NAMES`, but not the hardcoded list in approval.test.ts. That
list exists because the canary next to it skips when the API source is not
checked out, so it is the only gate a slack-agent-only checkout has — a
mutating tool missing from it ships to Slack without the approval prompt.

The iOS OpenAPI spec predates the `verifying` workflow state; regenerated.

* fix(ios): handle the verifying workflow state

Regenerating the OpenAPI spec added `verifying` to the workflow-state enum,
which made the three `WorkflowState` switches in Primitives.swift
non-exhaustive and failed the build. The spec was stale precisely because
this half was never done.

Teal, matching `workflow-badge.tsx` and for the reason its comment gives: a
merged fix being watched is distinct from `inReview`, where a human is
looking, and from `done`, where it is over. Token values are Tailwind's
teal-600/400 text and teal-500 fill, the same pair the web badge resolves to.

The state-filter picker builds from `allCases`, so it picks this up on its
own — correct here, since `verifying` is filterable even though it is
machine-owned and never offered as a manual transition.

* refactor(errors): decode the investigation subject discriminator with Schema

`subjectTypeOf` hand-rolled its narrowing — a `typeof` check, an `as` cast to
read `.type`, and a bare `string` out — for a value that has a schema.

Now `Schema.decodeUnknownOption` over an `InvestigationSubjectDiscriminator`,
returning `Option<InvestigationSubjectType>`. The literal set is derived from
`InvestigationSubject.members` rather than restated, so a fourth subject type
cannot leave it behind, and the guard is `Option.contains` against a narrowed
union instead of a magic string compared to `string`.

It decodes the discriminator ALONE, deliberately, and the tests pin that.
Reading the whole `InvestigationSubject` would answer `None` for a row whose
other fields drifted or predate the current shape — and `None` is the
dangerous answer here, not the safe one: it means "not a verification", which
is what lets a routine post-merge run re-rank a severity a human already set.
Excess-key tolerance is what makes a real stored subject decode at all, so
that is asserted rather than assumed.

Also covers `deterministicInvestigationEventId`, whose output is decoded
through a branded id whose UUID check would reject a bad version nibble.

* refactor(errors): the diagnosis writers own their database call

`applyDiagnosisWrites` and `applyInconclusiveWrites` took a raw client and
returned a Promise, so every caller had to remember to wrap them and supply
the retry, the span and the error mapping. Both now return an Effect and
reach `Database` themselves.

The drizzle callback inside stays a Promise, and that is the honest end of
this: `DatabaseApi.execute` is typed `(db) => Promise<T>` precisely because
drizzle's `transaction` is a Promise API with no Effect-native equivalent
here — the same shape a dozen sibling services use. Moving the wrapper inside
puts the seam at one place per logical call instead of at every call site;
it does not pretend the Promise is gone.

They fail with the raw `DatabaseError` rather than a domain persistence
error. The write straddles two domains — `investigations`, plus the
error-issue tables when an issue is linked — and the two callers map
differently, so mapping here would force one of them to re-wrap.
`InvestigationService` keeps its channel with `Effect.mapError` and provides
its captured `Database`; the contention retry is unaffected, living in
`makeDbExecute` rather than in the mapping.

The fan-out workflow gets `runDbStep`, which backs `Database` with the run's
own `PgConnectionScopeApi` — its `run` has exactly `execute`'s signature — so
every statement stays on the single connection that run opens and closes in
its `finally`, rather than dialing a socket per step. The one call site that
bundled a lens-run update with the diagnosis write is now two calls: those
were never atomic with each other, since `connection.run` is a span around a
logical call and not a transaction.

* fix(errors): claim the issue on the paths agents actually take

Across 50 live issues in the internal org, `lease_holder` was null on every
single one — `claim_error_issue` had never been called in production. The lease
existed to stop two agents fixing the same bug and had never once been held.

Claiming was not being skipped out of laziness. `triage -> in_review` is
deliberately not a legal edge, but `propose_fix` transitioned straight to
`in_review`, so proposing a fix on an untriaged issue — the state agents find
issues in — failed with "Illegal transition from 'triage' to 'in_review'". It
was the most common MCP error in the org, 18 occurrences in two days, and it
failed *after* writing the fix_proposed event and linking the PR. Agents
responded by hand-walking `transition_error_issue` instead, which works and
leaves the lease empty.

So the claim is now taken by the work rather than asked for beforehand:

- `propose_fix` acquires the lease and walks to `in_review` via a route the
  matrix actually permits (`fixProposalRoute`), from any state an issue can be
  worked from. Everything refusable is refused before anything is written, so a
  rejected call no longer leaves a half-written proposal.
- A transition to `in_progress` acquires the lease too — that is the path the
  agents actually used. Best-effort: another holder's lease is left alone
  rather than refusing a state change the caller is entitled to make.
- `propose_fix` can now fail with a lease conflict, which is the duplicate-work
  collision the lease exists to catch, finally caught where agents work.
- A `pr_url` that is not a pull request URL is a 400 instead of being swallowed
  and then reported back as `- PR: <url>`, which told an agent the fix was
  attached and would be verified when nothing had been linked at all.
- An illegal transition now names the moves that would work.
- `TERMINAL_WORKFLOW_STATES` -> `CLOSED_WORKFLOW_STATES`: `done` reopens to
  `regressed`, `verifying` and `triage`, so "no further transition is possible"
  was false. Both call sites mean "the work is over, drop the lease".
- `describeWorkflowTransitions` stops advertising machine-owned targets the
  transition tool then rejects.

Verified two ways: deterministic tests pinning the exact production failure
(triage to in_review end to end, the lease actually held, no fix_proposed on a
refused call, the lease-conflict path), and a new model-driven eval confirming
a real model still reaches for the claiming tools.

* fix(web): lead the copy-as-prompt with the claim, and stop calling it optional

The "Copy as prompt" text is what users paste into their coding agent, so it is
the instruction that actually reaches one. It read:

  5. Optionally claim the issue first with `claim_error_issue`, and once you
     have a patch, record it with `propose_fix`.

Last of five steps, after "propose a concrete fix", prefixed with "Optionally".
Not one issue in the internal org had ever been claimed.

The claim now leads and is unconditional, and the prompt closes with "Start by
claiming the issue." Also adds a `list_error_issue_events` step so an agent
reads Maple's own AI diagnosis and any earlier attempt before re-fixing a known
bug — guarded on the issue id, since that tool takes one and the legacy
errors-by-type page has none.

* refactor(errors): share the investigation fan-out start

`maybeEnqueueTriage` and `enqueueFixVerification` carried byte-identical copies
of the binding shape check, the `markFailed` update, the `Exit`-wrapped
`create()`, and the outcome span annotations — including two separate
declarations of `isFanoutWorkflowBinding`.

What differs between them is the subject, the snapshot and the insert, which
encode what a run is about. Starting it does not, so that half moves to
`startInvestigationFanout`. The verification path keeps its
`maple.verification.id` annotation through an `annotations` parameter: the tick
reads the start outcome back and can answer `no_binding` with a terminal verdict
that auto-closes the issue, so losing that attribute would leave the trace of
the close saying nothing about no agent having run.

* fix(query-engine): keep synthetic fingerprints out of toUInt64

`error_issues.fingerprint_hash` is shared by three issue kinds: "error" rows
hold the decimal UInt64 from ClickHouse, while "alert" and "integration" rows
reuse the column for a synthetic key (`alert:{ruleId}:{groupKey}`,
`planetscale:{database}:{event}`). `toUInt64('alert:…')` does not skip that
value, it aborts the whole query — so one alert-backed issue in a batch of
twenty 500s the request.

#573 filtered the list in the errors hub, which fixed the surface that was
firing. This is the same guard on the side no other caller can bypass:
`fingerprintHashIn` drops what the warehouse could not hold, which is exact
rather than lenient — a synthetic key cannot have error events by construction,
so nothing a caller could have matched is lost. An all-synthetic list emits a
false literal, because `IN ()` is a ClickHouse syntax error.

* fix(web): one workflow-state label map, and say whose move it is

Four copies of the workflow-state labels existed and the one in
`workflow-badge.tsx` had drifted — "Wontfix" where the ring, the state picker
and the bulk bar all said "Won't fix". Both duplicates now import
`WORKFLOW_LABEL`; the badge keeps only its tones.

A `verified` verdict on a high or critical issue deliberately does not close it
— the verdict is posted and a human decides. The card said "The fix holds" while
the issue sat open, with nothing to tell you whether Maple was still working or
waiting on you. It now says.

* docs(errors): write down the issue lifecycle, and tell agents the write flow

The `maple://instructions` MCP resource covered read-only observability and
stopped there — nothing about issues, leases, PR linking or verification. Agents
were given the whole write surface and no map of it, which is half of why the
flow was walked by hand.

Adds an "Error Issue Lifecycle" section: the flow end to end, the six rules that
matter (claim, read the timeline first, attach the PR, don't close an issue you
linked a PR to, `regressed`/`verifying` are not yours to set, severity drives
escalation), and a tool-picking list.

Adds `docs/error-issue-lifecycle.md` as the human counterpart — the entity model,
who owns which edge and why, the three places AI enters, and why verification
asks its question inverted. It also records what happened when claiming was a
rule an agent was merely told to follow, so the next tool that starts work on an
issue takes the lease itself.

The `transition_error_issue` and `link_pull_request` descriptions pick up the
same rules, so an agent that never reads the resource still gets them.

This branch had an error being deployed

1 failed deployment
pr-preview — bb316998 Deployed Aug 23, 2026 by JeremyFunk via deploy-pr-preview #1453
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant