Last updated: 2026-05-28 — Added two payment-related medium entries
under Bindu Core after #563/#565 landed settle-first ordering:
x402-settle-false-negative-silent-orphans (facilitator timeout vs
Base confirmation race creates undetected orphan payments) and
x402-no-auto-refund-for-orphan-payments (Bindu has no outbound-wallet
path; refunding is manual ops work). Both share the EIP-3009 recovery
metadata persisted by _settle_payment in
bindu/server/workers/manifest_worker.py;
the detection (reconciliation worker) and remediation (auto-refund)
pieces are scoped out as separate follow-ups.
Earlier: 2026-05-12 — Added skale-facilitator-cert-expired
(low/ops) under Bindu Core after adding operator-extensible network
support to X402Settings.extra_networks and the register_money_parser
plumbing in applications.py. SKALE-aware facilitator
facilitator.x402.fi has an expired TLS cert and is the only public
endpoint that advertises SKALE chains in its /supported response; the
shipped default settings include skale-europa so the structure is
visible, but operators need to bring their own working facilitator URL
to actually settle. See
tests/integration/x402/test_skale_facilitator_supported.py
for the opt-in smoke check.
Earlier: 2026-05-12 — Four x402 payment-bypass entries removed
(x402-middleware-fails-open-on-body-parse, x402-no-replay-prevention,
x402-no-signature-verification,
x402-balance-check-skipped-on-missing-contract-code). The middleware
was rewritten against x402 SDK v2 (x402ResourceServer.verify_payment
replaces the hand-rolled _validate_payment_manually) and a new
Redis-backed nonce store (bindu/server/middleware/x402/nonce_store.py)
enforces replay prevention before the facilitator round-trip. See
core/2026-05-12-x402-v2-migration-hardening.md.
Previously: 2026-04-26 — hydra-token-cache-revocation-lag removed. The Hydra middleware now skips its introspection cache for sensitive scopes (admin, agent:execute, payment:capture, key:rotate) and exposes invalidate_token_cache / revoke_token for in-process invalidation. See core/2026-04-26-hydra-token-cache-revocation-lag.md. 2026-04-20 — recipes/SSE/DID work on branch feat/gateway-recipes added 15 gateway entries (5 medium, 6 low, 5 nit) and narrowed two existing ones (tool-name-collisions-silent → parse-agent-from-tool-greedy-mismatch, signature-verification-ok-when-unsigned → signature-verification-non-text-parts-unverified) to the residual sub-bugs after partial resolutions landed. did-document-endpoint-returns-raw-dict removed — see core/2026-04-19-did-document-endpoint-raw-dict.md.
This file is user-facing. It's the first thing a new contributor or operator reads to calibrate expectations: what Bindu doesn't do today, and what behaves in surprising ways. Every entry has a workaround where one exists.
- Scan the tables first. Each subsystem (Gateway, Bindu Core, SDKs, Frontend) starts with a one-line-per-issue table — slug, severity, short description. That's enough to decide whether an issue matters to your deployment.
- Read the story when it bites you. High-severity entries have a Scenario block telling you what the bug looks like from the outside. Start there when you suspect you're hitting one of these.
- Medium / low / nit entries stay terse — Summary / Workaround / Tracking — because they're either easy to understand or easy to work around.
Entries are REMOVED from this file as issues are fixed. If the
fix teaches a generalizable lesson, a dated postmortem lands in the
matching bugs/<subsystem>/ folder:
bugs/gateway/— gateway postmortemsbugs/core/— Bindu Core (Python) postmortemsbugs/sdk/— SDK postmortems (none yet)
See bugs/README.md for the postmortem template.
Reporting: if you hit one of these and want to help, open a GitHub
Issue referencing the slug (e.g. "Fixes context-window-hardcoded").
| Subsystem | High | Medium | Low | Nit |
|---|---|---|---|---|
| Gateway | 2 | 14 | 19 | 9 |
| Bindu Core (Python) | 0 | 8 | 3 | 0 |
| SDKs (TypeScript) | — | — | — | — |
| Slug | Severity | One-line |
|---|---|---|
context-window-hardcoded |
high | Compaction threshold assumes a 200k-token context window |
no-session-concurrency-guard |
high | Two /plan calls on the same session tangle their histories |
permission-rules-not-enforced-for-tool-calls |
medium | Permission service exists but is never called for tool calls |
parse-agent-from-tool-greedy-mismatch |
medium | SSE agent field wrong when agent names contain underscores |
agent-catalog-overwrite |
medium | Each /plan wholesale-overwrites the session's agent catalog |
signature-verification-non-text-parts-unverified |
medium (sec) | DataPart / FilePart bypass signature verification |
pinned-did-no-format-validation |
medium | Any string is accepted as pinnedDID; no DID shape check |
recipe-permission-ask-treated-as-allow |
medium | permission: recipe: { "x": "ask" } silently allows |
ctx-ask-not-wired-for-recipes |
medium | Recipe permission hook is a no-op at runtime |
supabase-error-surfaces-as-generic-sse-error |
medium | Mid-stream DB failures emit untyped event: error |
did-resolver-no-key-id-selection |
medium (sec) | Resolver picks the first public key; breaks during rotation |
list-messages-pagination-silent |
medium | Sessions >1000 messages silently truncate oldest |
tool-input-sent-as-textpart |
medium | Skills expecting DataPart receive TextPart with stringified JSON |
no-rate-limit-cors-body-size-limit |
medium | No rate limit / CORS / body-size limit on the Hono app |
prompt-injection-scrubbing-theater |
medium (sec) | The regex scrubber offers false confidence |
no-graceful-shutdown |
medium | In-flight /plan streams drop mid-frame on close |
assistant-message-lost-on-stream-error |
medium | Mid-stream errors lose completed tool calls from history |
json-schema-to-zod-incomplete |
medium | Converter ignores enum / oneOf / pattern / etc. |
compaction-dedupe-single-process-only |
low | Dedupe is in-process; horizontal scaling would race |
no-ttl-cleanup |
low | session.ttlDays configured but no cleanup job runs |
token-estimation-chars-div-4 |
low | chars/4 wrong for code and CJK |
did-resolver-no-stampede-protection |
low | Simultaneous cache-miss fetches for the same DID |
bearer-env-error-collapses-to-transport |
low | Missing env var surfaces as a generic transport error |
resume-race-duplicate-session |
low | Concurrent first-time /plan for a session throws 500 on second |
cancel-casing-not-retried |
low | tasks/cancel sends only camelCase; leaks tasks on snake-case peers |
health-endpoint-no-dependency-probe |
low | GET /health doesn't probe dependencies |
no-request-id-in-logs |
low | No correlation ID for /plan requests |
no-config-hot-reload |
low | Config changes need a full restart |
resolve-env-limited-to-simple-var |
low | Env interpolation only matches bare $VAR |
compaction-summary-injected-as-user-role |
low | Summary injected as role: "user" — could confuse the LLM |
revert-millisecond-ties-nondeterministic |
low | Millisecond-tied created_at ordering is non-deterministic |
revert-doesnt-cancel-remote-tasks |
low | revertTo doesn't cancel still-running peer tasks |
empty-agents-catalog-no-400 |
low | /plan with empty agents[] runs and returns an LLM error |
no-migration-rollback |
low | Migrations forward-only — no paired down.sql |
recipe-no-hot-reload |
low | Recipe changes require a gateway restart |
load-recipe-sse-frames-ambiguous |
low | load_recipe and peer calls use the same SSE agent field |
recipe-file-enumeration-no-truncation-signal |
low | 10+ files per recipe silently truncated in tool output |
faq-agent-did-name-mismatch |
low | Fleet's faq_agent.py registers as bindu_docs_agent |
fleet-env-can-desync-after-seed-rotation |
low | Stale $*_DID env vars survive agent seed rotation |
provider-openrouter-hardcoded |
low | model field assumes OpenRouter prefix |
tasks-recorded-is-dead-state |
nit | Unused tasksRecorded field in the planner |
map-finish-reason-pointless-ternary |
nit | Conditional type that evaluates to any either way |
db-effect-promise-swallows-errors |
nit | Effect.promise silently swallows rejected promises |
test-coverage-gaps |
nit | Backlog list of missing test scenarios |
accept-header-not-enforced-on-plan |
nit | Accept: text/event-stream not required |
openapi-sse-schemas-unused |
nit | 13 redocly no-unused-components warnings on SSE schemas |
no-planner-integration-test |
nit | Request → SSE end-to-end test missing |
no-health-handler-integration-test |
nit | /health only has pure-helper unit tests |
story-chapter5-missing-oauth-scope-explanation |
nit | DID chapter assumes reader knows OAuth scopes |
Severity: high
Scenario. You switch the gateway to GPT-4o-mini (128k context). After a few long turns, the next planner request sends >128k tokens to the provider and gets back a
context_length_exceeded400. You check the logs expecting to see compaction kick in. It didn't. The threshold is hardwired to 200k.
What's wrong. The compaction threshold in
gateway/src/session/overflow.ts
assumes a 200,000-token context window (Claude Opus 4.x). Smaller
models (GPT-4o-mini 128k, Haiku 200k) or much larger ones (Gemini
Flash 1M) make compaction fire at the wrong time — either too late
(provider rejects for overflow before compaction runs) or too
early (unnecessary summarization cost).
Workaround: Pass an explicit threshold.contextWindow
matching your actual model when calling compactIfNeeded. For
multi-model deployments this needs per-model configuration the
gateway doesn't currently expose.
Status: ✅ Fixed. thresholdForModel() in
gateway/src/session/overflow.ts
now resolves the context window from a lookup table keyed on
provider/modelId (Anthropic 4.x = 200k, GPT-4o/4o-mini = 128k,
GPT-4.1 = ~1M, o3 = 200k). Unknown models fall back to 128k
(conservative — triggers compaction earlier rather than letting
the caller hit context_length_exceeded). Operators can still
pass an explicit override via compactIfNeeded({threshold}) for
exotic models. Threshold wiring goes through
compaction.ts:compactIfNeeded which now takes the planner's
actual model into account. Guarded by
gateway/tests/session/overflow-threshold.test.ts
(14 tests).
Tracking: (no issue yet)
Severity: high
Scenario. Alice has two browser tabs on the same session. Tab 1 sends
/plan. Before it finishes, tab 2 sends another/planfor the same session. Both planners append togateway_messagesconcurrently. The second LLM call sees the first call's half-writtentool_usewithout its pairedtool_result— the assistant message sequence is now broken. LLM hallucinates or errors. Worse: the session's history is now permanently in an inconsistent state on disk.
What's wrong. Two /plan requests sharing the same
session_id both append to gateway_messages with no
serialization. Their histories interleave and the LLM's
tool_use / tool_result pairing silently tangles across
sessions. The compaction dedupe fix (commit 0655ac1)
prevents compaction races but does not serialize plan turns
themselves.
Workaround: Clients should not run concurrent /plan
requests against the same session. For genuine concurrent use
(multiple tabs, etc.), use distinct session IDs and reconcile
externally.
Status: ✅ Fixed. withSessionLock in
gateway/src/planner/index.ts
serializes runPlan per sessionID via a per-key promise
chain (Map<SessionID, Promise<unknown>> scoped to the planner
layer). The second /plan call awaits the first's tail before
its body starts, so message-history writes can no longer
interleave. Effect.ensuring releases the lock on success,
failure, OR interruption — an aborted or crashed plan can't
deadlock the next caller. Prior errors are swallowed by the
chain so a single failure doesn't poison the session forever.
Different session_ids are unaffected (separate map keys).
Limitation mirrors the compaction dedupe: per-process state
only — multi-process gateway deployments would still need a
Postgres advisory lock or a gateway_sessions version column
with optimistic-concurrency on the message insert (Phase 2).
Guarded by
gateway/tests/planner/session-lock.test.ts
(4 tests: serialization, cross-session independence, failure
recovery, tail cleanup).
Tracking: (no issue yet)
Severity: medium
Summary: agents/planner.md declares permission: agent_call: ask, and the Permission service exists and evaluates wildcards
correctly. But the planner's tool-execution path
(gateway/src/session/prompt.ts)
never calls Permission.Service.evaluate() before running a Bindu
tool. The permission system is effectively dead code for tool calls
today.
Workaround: Control tool access via the agents[] catalog the
caller sends in the /plan request — only include agents the caller
is allowed to invoke.
Tracking: (no issue yet)
Severity: medium
Summary: The collision half of this bug is now
rejected at plan-open time — findDuplicateToolIds in
gateway/src/planner/index.ts
returns a 400 when two (agent, skill) pairs normalize to the
same tool id. What remains: parseAgentFromTool in
gateway/src/api/plan-route.ts
uses a non-greedy regex ^call_(.+?)_(.+)$, so an agent whose
name contains an underscore (e.g. research_v2) emits SSE
task.started events with agent=research, skill=v2_x instead
of the intended agent=research_v2, skill=x.
Workaround: Avoid underscores in agent names in the catalog
you send on /plan. If you must, rely on agent_did / task_id
for correlation, not the SSE agent field.
Tracking: (no issue yet)
Severity: medium
Summary: db.updateSessionCatalog wholesale-overwrites
gateway_sessions.agent_catalog on every /plan. If the caller
omits an agent from a subsequent turn's catalog (e.g. temporary
unreachability, inventory churn), the gateway drops it from the
session's recorded catalog even though the session's history
references its prior tool calls. Also exposed to a concurrency race
(see 2026-04-18-compaction-concurrent-races.md
— same shape, different column).
Workaround: Always send the full agent catalog on every turn,
even if individual agents are temporarily unavailable.
Tracking: (no issue yet)
Severity: medium (security-adjacent)
Summary: The envelope-ambiguity half of this bug is fixed
— <remote_content verified="..."> is now four-valued
(yes | no | unsigned | unknown), so the planner LLM can tell a
real cryptographic pass from "nothing was signed". What remains:
verifyArtifact in
gateway/src/bindu/identity/verify.ts
only inspects text parts. file and data parts are never
verified, so a peer moving payload into a DataPart bypasses
signature checks entirely even when verifyDID: true is set.
Workaround: Refuse peers that return data or file parts
for responses that must be verified. The gateway does not enforce
this constraint today.
Tracking: (no issue yet)
Severity: medium (security-adjacent)
Summary: primaryPublicKeyBase58 in
gateway/src/bindu/identity/resolve.ts
picks the FIRST public key found in a peer's DID Document. If the
peer publishes multiple keys — e.g. during a rotation window listing
both old and new — the wrong key may be selected for verification,
causing valid signatures to fail or the wrong key to be used
altogether. The A2A DID spec allows explicit keyId selection in
signature metadata; the gateway does not consult it.
Workaround: For peers using DID verification, pin them to a
specific DID via trust.pinnedDID and coordinate rotation windows
out-of-band.
Tracking: (no issue yet)
Severity: medium
Summary: db.listMessages has a default limit of 1000 rows
(gateway/src/db/index.ts). Very long
sessions silently truncate to the most recent 1000 messages, skipping
the oldest. The planner loads this into its history; the LLM sees a
partial session that starts mid-stream. No error, no warning.
Compaction can fire and accurately summarize what it sees, but the
oldest messages were never in scope.
Workaround: Trigger compaction early on sessions expected to
grow large. Long-term, paginate via created_at cursor.
Tracking: (no issue yet)
Severity: medium
Summary: The planner's tool-execution path
(gateway/src/planner/index.ts)
serializes arguments with JSON.stringify(args) and sends them as a
Bindu TextPart. Many deployed skills expect a structured
DataPart ({kind:"data", data:{…}}) — especially skills whose
server-side validator parses a typed input schema. Skills that only
accept DataPart reject the TextPart outright; skills that try to
parse the JSON string out of a text field behave unpredictably.
Workaround: None client-side — the gateway always sends
TextPart. Affected skills must accept either form on their server
side until this is fixed.
Tracking: (no issue yet)
Severity: medium
Summary: The Hono app
(gateway/src/server/index.ts)
has no rate limit, no CORS policy, and no body-size limit. A single
client can submit large /plan requests or fire many concurrent
requests without any throttling; browser clients from different
origins have no explicit policy. The absence of a body-size limit is
a trivial DoS vector — a 500 MB JSON payload is accepted, parsed,
and held in memory.
Workaround: Deploy behind a reverse proxy (nginx, Cloudflare,
API Gateway) that provides these controls. The gateway assumes it
runs behind such a proxy today.
Tracking: (no issue yet)
Severity: medium (security)
Summary: wrapRemoteContent in
gateway/src/planner/index.ts
strips literal strings like "ignore previous" and
"disregard earlier" from peer responses before handing them to the
planner LLM. This is trivially bypassable with capitalization,
Unicode homoglyphs, paraphrasing, JSON-encoding the instruction, or
placing it in a non-text part (file, data — which aren't
scrubbed at all). It offers a false sense of safety without actually
blocking prompt injection; the current defense is worse than no
defense because downstream code may assume it's doing something.
Workaround: Do not rely on the scrubber for prompt-injection
resistance. For untrusted peers, apply one or more of: (a) isolate
peer output into an LLM sub-call with its own restricted system
prompt that produces structured data, not free-form instructions;
(b) use provider-side structured-output / tool-choice constraints to
prevent the planner from obeying arbitrary peer instructions;
(c) cap peer responses to a strict JSON schema server-side.
Tracking: (no issue yet)
Severity: medium
Summary: The gateway's close() function
(gateway/src/index.ts) calls
httpServer.close() and runtime.dispose() back-to-back. In-flight
/plan streams are dropped mid-frame — clients see a truncated
SSE, and the assistant message may be partially written but not
committed. No draining, no deadline, no 5xx return for requests in
flight during a rolling restart.
Workaround: Rely on the reverse proxy to drain connections
before sending SIGTERM to the gateway, and run at least two gateway
replicas so dropped connections can be retried against the other.
Tracking: (no issue yet)
Severity: medium (data loss, billing)
Summary: If the LLM stream errors mid-turn
(gateway/src/session/prompt.ts),
the Effect generator fails immediately. Tool calls that already
completed (and were already billed via the Bindu agent) are lost
from the assistant message — they never get persisted to
gateway_messages. The audit row in gateway_tasks still exists
(the tool call completed from the gateway's perspective), but the
session-level history shows no record of the tool_use, so replay is
inconsistent with the audit log.
Workaround: None at the application level. Operators should
correlate gateway_tasks with gateway_messages when investigating
session gaps — don't trust the assistant-message view alone.
Tracking: (no issue yet)
Severity: medium
Summary: jsonSchemaToZod in
gateway/src/planner/index.ts
converts an inbound skill's inputSchema to a Zod validator for the
planner LLM. It handles type: string|number|integer|boolean|array| object but ignores every other keyword: enum, oneOf, anyOf,
pattern, minLength/maxLength, minimum/maximum,
additionalProperties, format, and more. The planner LLM therefore
receives no signal about valid values; invalid input passes local
validation and reaches the peer, failing late with a JSON-RPC error.
Workaround: Skills should document their full input constraints
in their human-readable description so the planner LLM picks them
up from the prompt rather than the schema.
Tracking: (no issue yet)
Severity: low (correct today, architectural ceiling)
Summary: The fix for concurrent compaction races
(commit 0655ac1 /
2026-04-18-compaction-concurrent-races.md)
uses an in-process Map<SessionID, Promise>. A horizontally-scaled
deployment of the gateway (multiple Node processes fronting one
Supabase) could still race across processes. Single-process Phase 1
is correct.
Workaround: Run a single gateway process. Horizontal scaling is
a Phase 2 concern — when it lands, add a Postgres version column on
gateway_sessions with optimistic-concurrency semantics in the
compaction UPDATE, or wrap the whole compaction in a stored
procedure.
Tracking: (no issue yet)
Severity: low
Summary: Config declares gateway.session.ttlDays (default 30)
but nothing actually cleans up old sessions. gateway_sessions,
gateway_messages, and gateway_tasks grow unbounded.
Workaround: Run a scheduled Supabase SQL job to delete rows
older than your desired TTL. The gateway does not do this
automatically.
Tracking: (no issue yet)
Severity: low
Summary: approxTokens in
gateway/src/session/overflow.ts
uses chars / 4 as its token count heuristic. Accurate for English
prose; wrong for code (more tokens per char due to punctuation),
wildly wrong for CJK (closer to chars / 1.5). Combined with
context-window-hardcoded, compaction timing for non-English
sessions is unreliable.
Workaround: Set a more conservative triggerFraction (e.g.
0.6 instead of 0.8) if your sessions are primarily code or
CJK-language content.
Tracking: (no issue yet)
Severity: low
Summary: The DID resolver in
gateway/src/bindu/identity/resolve.ts
caches DID Documents with a 5-minute TTL. When the cache expires (or
is cold on first call), concurrent resolve() calls for the same
DID all miss and issue simultaneous HTTP fetches to the peer's
/did/resolve. Functionally harmless; wasteful.
Workaround: None needed in practice — 5 minutes is long enough
that stampedes are rare. If they become a problem, add a
second-level in-flight dedupe (same pattern as compaction-dedupe).
Tracking: (no issue yet)
Severity: low
Summary: When auth: { type: "bearer_env", envVar: "FOO" } is
configured for a peer but $FOO is unset, authHeaders() throws
from inside the async runCall in the Bindu client. The error is
caught by the Effect machinery and wrapped as a generic transport
error (BinduError.transport(...)), losing the "configuration
missing" context. The operator can't tell "missing env" from "peer
down."
Workaround: Validate required env vars at gateway boot. Or tail
the logs for "auth: env var … is not set" — the message is still
present in the error string, just buried under "transport:".
Tracking: (no issue yet)
Severity: low
Summary: Two concurrent /plan requests with the same
session_id where neither yet exists in gateway_sessions will
both miss the getSession lookup and both call sessions.create(),
producing two rows with the same external_session_id. The UNIQUE
constraint on external_session_id will cause the second insert to
fail — the second request errors back to the caller with a 500.
Workaround: Retry the failing request. The first insert
succeeded, so a retry resolves to the existing row.
Tracking: (no issue yet)
Severity: low
Summary: When sendAndPoll exhausts its poll budget
(gateway/src/bindu/client/poll.ts),
it issues a best-effort tasks/cancel to the peer. That cancel
sends taskId in camelCase only — it never does the snake_case
retry flip that the poll loop itself performs. For peers that
require task_id in snake_case (the very case the flip exists for),
the cancel silently fails and the remote task leaks.
Workaround: None. Peers that require snake-case params for
tasks/cancel need to support camelCase too, or the task will
orphan on poll timeout.
Tracking: (no issue yet)
Severity: low
Summary: GET /health
(gateway/src/server/index.ts)
returns 200 {ok: true} regardless of whether Supabase is reachable
or the configured LLM provider is accepting requests. Load balancers
see "healthy" while the gateway is unable to serve /plan traffic.
Good for liveness, useless for readiness.
Workaround: Add a separate readiness check in your deployment
that hits /plan with a no-op payload and a short deadline.
Tracking: (no issue yet)
Severity: low
Summary: /plan handler produces no request ID, correlation
ID, or tracing context. When an SSE stream errors and the client
reports a problem, there's no way to correlate the client's
observation with a server-side log line. Server-side logs don't
include session ID either for most events.
Workaround: Set X-Request-Id on the reverse proxy and include
it in proxy access logs; correlate by timestamp and peer URL.
Tracking: (no issue yet)
Severity: low
Summary: Changes to agents/planner.md, gateway.config.json,
or any permission rules require a full gateway restart. The config
loader reads files once at boot via
gateway/src/config/loader.ts.
Workaround: Restart the gateway. For live-tuning the planner
prompt, consider reading it dynamically from DB or a separate
mutable source.
Tracking: (no issue yet)
Severity: low
Summary: resolveEnv in
gateway/src/config/loader.ts
only matches bare "$VAR" strings. It does not handle
"${VAR}/suffix", default values ("${VAR:-default}"), nested
interpolation, or shell-style expansion. A config value like
"https://${HOST}/api" passes through as a literal string.
Workaround: Precompute interpolated values in your env or
config file; don't rely on shell-style expansion inside config
strings.
Tracking: (no issue yet)
Severity: low
Summary: The compaction summary is injected back into history
as a synthetic message with role: "user"
(gateway/src/session/index.ts).
Works, but the LLM may mistake the summary for the current user
turn's message — especially when the summary starts with a phrase
the model could interpret as a directive. A system-role injection
(or explicit [SYSTEM: prior context] tagging) would be safer.
Workaround: The prefix [Prior session context, compacted]
already signals the nature of the content; in practice the planner
handles it correctly. Watch for cases where the model echoes the
summary verbatim as if it were a user question.
Tracking: (no issue yet)
Severity: low
Summary: revertTo and revertLastTurn in
gateway/src/session/revert.ts
use created_at as the boundary for "everything after this
message." If multiple messages were inserted within the same
millisecond (rare but possible under contention), their relative
order is non-deterministic — revert may include or exclude some of
them based on DB-internal ordering.
Workaround: Inspect the reverted row set after a revert;
manually unmark any mis-reverted rows. A Phase 2 fix would switch to
a monotonically-increasing seq column.
Tracking: (no issue yet)
Severity: low
Summary: revertTo marks local audit rows as reverted=true
but does NOT send tasks/cancel to the peers for any still-running
Bindu tasks in the reverted window. The remote tasks continue,
consuming peer resources and (for paid skills) accruing cost until
they finish on their own. This is documented as intentional in the
code — peers have already done the work, and cancel semantics are
complex — but may surprise operators.
Workaround: Accept that revert only clears local state.
Stragglers complete on the peer side and their audit rows stay
marked reverted so they're hidden from resume.
Tracking: (no issue yet)
Severity: low
Summary: PlanRequest.agents has a default of [], so a
/plan request with no agents is accepted. The planner runs with
zero tools, the LLM attempts to call a tool it has no access to,
and the response is an error message from the LLM — not a clear
"you forgot to send agents" 400.
Workaround: Always include at least one agent in the request,
or pre-validate the agents.length client-side.
Tracking: (no issue yet)
Severity: low
Summary: Migrations under gateway/migrations/ are forward-only
— no paired down.sql for each change. Reverting a migration
requires manual SQL work.
Workaround: For a production deployment that may need rollback,
maintain rollback scripts outside the migrations/ folder. This is
a common choice for small projects; fix only if the team actively
needs reversible migrations.
Tracking: (no issue yet)
Severity: nit
Summary: tasksRecorded: string[] accumulated in the planner
(gateway/src/planner/index.ts)
is populated inside buildSkillTool but never returned via SSE,
never persisted, and never read. Dead code.
Workaround: None needed. Remove in a cleanup pass.
Tracking: (no issue yet)
Severity: nit
Summary: mapFinishReason in
gateway/src/session/prompt.ts
has a parameter type StreamEvent["type"] extends "finish" ? any : any which is always any. The conditional type adds no
information.
Workaround: None needed. Simplify in a cleanup pass.
Tracking: (no issue yet)
Severity: nit (correctness-adjacent)
Summary: Two paths in
gateway/src/db/index.ts use
Effect.promise(...) which resolves even when the underlying
promise rejects (it treats rejection as a defect, not an Effect
error). Transient Supabase failures at those call sites may silently
resolve without being surfaced to the caller.
Workaround: None client-side. Audit Effect.promise call sites;
prefer Effect.tryPromise with an explicit catch for any
non-trivial operation.
Tracking: (no issue yet)
Severity: nit
Summary: The test suite does not cover: concurrent /plan
requests end-to-end (only the pubsub filter is tested); compaction
correctness on long multi-pass sessions (only the wrapper dedupe and
summarizer prompt are tested); revert; SSE frame ordering under
load; non-English payloads; sessions larger than the 1000-row
pagination limit; missing bearer_env env vars; aborted requests
propagating to the Bindu client; the snake_case flip on
tasks/cancel.
Workaround: None — this is an internal backlog item.
Contributors tackling any of the fixable items above should add a
test for it in the same PR.
Tracking: (no issue yet)
Severity: medium
Summary: PeerAuthRequest.trust.pinnedDID in
gateway/src/planner/index.ts
is z.string().optional() with no shape check. A caller can send
pinnedDID: "hello" or accidentally an un-interpolated template
literal like "${RESEARCH_DID}", and the gateway will echo that
string in every SSE agent_did frame and try (vainly) to resolve
it for signature verification. Observed in practice when a Postman
user copy-pasted a bash-style variable reference into the request
body.
Workaround: Validate DID shape client-side before sending. At
minimum ensure the string starts with did:bindu: or did:key:.
Tracking: (no issue yet)
Severity: medium
Summary:
Recipe.available filters the
recipe list shown to an agent by excluding anything whose
permission.recipe resolves to deny. The evaluator is
three-valued (allow | deny | ask) — ask falls through and the
recipe is shown AND loadable. With the ctx.ask hook unwired (see
next entry), "ask" is silently identical to "allow".
Workaround: Treat "ask" as if it were "allow" for recipes
today. If you need to restrict, use "deny".
Tracking: (no issue yet)
Severity: medium
Summary: tool/recipe.ts
calls ctx.ask({permission: "recipe", target: name}) before
loading a recipe body, guarded by if (ctx.ask). The ctx.ask
field is marked optional in ToolContext and
wrapTool never sets it. So
the gate is a permanent no-op — recipes load unconditionally
regardless of agent permission config. This is a separate concern
from permission-rules-not-enforced-for-tool-calls which targets
the broader tool-call gate.
Workaround: Don't rely on permission.recipe in agent configs
for production access control. Wait for Phase-2 permission UI.
Tracking: (no issue yet)
Severity: medium
Summary:
plan-route.ts wraps
runPlan in a catch that emits event: error with only
{message: string}. Callers can't programmatically distinguish a
Supabase outage from a peer crash from an LLM failure — all three
look identical on the wire. Operators debugging prod incidents
have to grep logs.
Workaround: Tail gateway logs out of band. Client-side, treat
any event: error as "retry with exponential backoff" regardless
of cause.
Tracking: (no issue yet)
Severity: low
Summary:
Recipe.layer reads
gateway/recipes/ once at boot. Adding, editing, or deleting a
recipe markdown file has no effect until the gateway process
restarts. Authoring loop is Ctrl-C + npm run dev per change.
Workaround: Script npm run dev to auto-restart on
recipes/**/*.md changes using nodemon or tsx watch with a
wider include. Or edit + restart; the recipe layer init is cheap.
Tracking: (no issue yet)
Severity: low
Summary: When the planner calls the internal load_recipe
tool, the SSE stream emits task.started / task.artifact /
task.finished frames with agent: "load_recipe" and
agent_did: null. Consumers parsing task.* frames by peer
correlation have no crisp way to tell this apart from a peer call
that happens to have a null DID. The agent_did_source: null
field helps (peer calls from unpinned observed-failed peers also
have it null) but doesn't cleanly partition.
Fix: add an explicit tool_kind: "peer" | "local" field on
the task.* SSE frames.
Workaround: Filter on agent === "load_recipe" client-side.
Tracking: (no issue yet)
Severity: low
Summary: The
load_recipe tool returns a
<recipe_files> block listing sibling files inside a bundled
recipe's directory, capped at 10 entries. If a recipe directory
has more than 10 files, the excess silently disappears from the
list — the planner has no way to know it's seeing a sample, not
the full set.
Fix: include an explicit truncated: true marker in the tool
result's metadata, and emit a console.warn at boot when a recipe
directory exceeds the cap.
Workaround: Keep bundled recipe directories under 10 files.
Tracking: (no issue yet)
Severity: low
Summary: The fleet demo's
examples/gateway_test_fleet/faq_agent.py
registers its DID as bindu_docs_agent but the filename and the
operator-facing port labels (3778) say faq_agent. First-time
readers following docs/GATEWAY.md Chapter 3 see the mismatch in
the SSE agent_did strings vs the catalog agent field and
wonder if something is wrong. Python-side concern, no gateway code
involved.
Workaround: The mismatch is cosmetic — signature verification
and routing both work. Ignore or pin faq_agent DID explicitly in
the catalog.
Tracking: (no issue yet)
Severity: low
Summary:
examples/gateway_test_fleet/start_fleet.sh
writes fresh DIDs to .fleet.env on every run. But if an agent's
seed rotates (rm -rf ~/.bindu, restart) and a user has an old
shell with $RESEARCH_DID still sourced from a prior .fleet.env,
their next /plan pins a stale DID. Signature verification fails
with a cryptic mismatch error.
Fix: have start_fleet.sh print a "re-source .fleet.env if
you had one loaded previously" hint whenever any agent's DID
differs from the previous run's cache.
Workaround: Always source .fleet.env fresh after any fleet
restart.
Tracking: (no issue yet)
Severity: low
Summary:
gateway/src/provider/index.ts
and downstream code assume the openrouter/ model prefix. Adding
direct Anthropic or direct OpenAI support (without going through
OpenRouter's proxy) is a code change, not config.
Workaround: Use OpenRouter as the universal proxy; it supports
every major provider. Only reach for this when you need direct
provider features (e.g. Anthropic prompt caching, OpenAI
fine-tuned model access) that OpenRouter doesn't proxy.
Tracking: (no issue yet)
Severity: nit
Summary: POST /plan returns text/event-stream regardless
of whether the client sent Accept: text/event-stream. Clients
that forget the header still get SSE, which is convenient but
breaks strict content-negotiation semantics. Not currently
documented as required in
openapi.yaml either.
Fix: either document the header as required and return 406
when absent, or keep the permissive behavior and document it.
Currently we're in the worst middle ground.
Workaround: None needed; current behavior is operationally
fine. Consumers relying on strict 406 on wrong Accept won't get
it.
Tracking: (no issue yet)
Severity: nit
Summary: redocly lint gateway/openapi.yaml reports 13
no-unused-components warnings on the SSEEvent_* schemas.
OpenAPI 3.1 has no native SSE modeling, so those schemas sit as
reference material rather than being $ref'd from a response
body. The warnings are expected given the format's limitations,
not indicative of drift.
Fix options: (a) accept — pragmatic, 0 errors, just warnings;
(b) use oneOf inside the text/event-stream response schema to
enumerate each event shape (stretches OpenAPI); (c) publish a
separate AsyncAPI 2.x/3.x spec for the SSE surface.
Workaround: None needed; warnings don't break consumers.
Tracking: (no issue yet)
Severity: nit
Summary: The unit tests cover every Gateway module in
isolation, but no single test walks a /plan request
end-to-end with a mocked LLM provider. The recipes feature,
signatures surfacing, and observed-DID resolution were all
verified manually against the real stack and via targeted unit
tests for their pure helpers. A regression in the cross-cutting
glue (tool → Bus → SSE JSON) would be caught only at integration
time.
Fix: mock Provider.Service with a fake emitting a canned
StreamEvent sequence; assert SSE output matches expectations.
Workaround: None — treat this as internal backlog.
Tracking: (no issue yet)
Severity: nit
Summary:
tests/api/health-route.test.ts
covers only the pure helpers (splitModelId, deriveGatewayId,
deriveAuthor). The handler's full response shape — version,
planner-model nesting, runtime flags, uptime math — is verified
by manual curl, not by a test that builds the layer graph and
asserts the JSON. Drift between openapi.yaml's HealthResponse
schema and the actual response would go unnoticed until someone
hand-checks.
Fix: build a minimal layer graph (mock Supabase) in a test,
invoke the handler against a stub Hono context, assert the body
matches the openapi schema.
Workaround: Manual curl against a running gateway.
Tracking: (no issue yet)
Severity: nit
Summary:
docs/GATEWAY.md Chapter 5 sets
BINDU_GATEWAY_HYDRA_SCOPE via env vars but never explains what
OAuth scopes are or why agent:read + agent:write are the
defaults. A reader walking the story linearly hits the config
step without context.
Fix: one-paragraph sidebar in Chapter 5 explaining "scopes
are labels we ask Hydra to stamp on tokens; peers check them
before accepting a message/send".
Workaround: Cross-ref to gateway/README.md §DID signing which
explains it.
Tracking: (no issue yet)
| Slug | Severity | One-line |
|---|---|---|
authz-scope-check-behind-optional-flag |
medium (sec) | Scope check is optional; flipping the flag removes all authz |
cors-allow-credentials-with-user-origins |
medium (sec) | Credentials + loose origins risk credentialed CORS |
no-rate-limit-or-quota-per-caller |
medium | No per-caller quota; single caller can exhaust resources |
x402-settle-false-negative-silent-orphans |
medium | Facilitator /settle times out, chain confirms anyway, payer debited but task failed |
x402-no-auto-refund-for-orphan-payments |
medium | Orphan payments need a manual USDC transfer; Bindu has no outbound-wallet path |
Severity: medium (security, authorization)
Summary: The scope check in
bindu/server/endpoints/a2a_protocol.py
line 153 is wrapped in
if app_settings.auth.require_permissions:. When the flag is falsy
(common during bringup, demos, or debugging), the A2A endpoint
accepts any authenticated token for any method — there is no
authorization layer at all, only authentication. Authorization being
a feature flag is a deployment landmine: an operator who turns it
off to "unblock" something forgets to turn it back on and ships a
scopeless service.
Workaround: Always deploy with require_permissions: true and
define per-method scopes in auth.permissions. Treat the flag as
deprecated in your configuration and add a startup assertion that
refuses to boot when the flag is false and auth is enabled.
Tracking: (no issue yet)
Severity: medium (security, CORS misconfig)
Summary:
bindu/server/applications.py
lines 563–571 instantiate CORSMiddleware with
allow_credentials=True, allow_methods=["*"], allow_headers=["*"],
and allow_origins=cors_origins where cors_origins is an
operator-supplied list. Starlette does reject the literal wildcard
["*"] with credentials, but an operator passing
["https://example.com", "null"], a reflected-origin scheme, or
simply an over-broad list (every internal tool) still gets a
credentialed cross-origin surface. There is no startup assertion
that the supplied origins are compatible with allow_credentials=True.
Workaround: Set cors_origins to an exhaustive, minimal list
of known origins. Never include "null", "*", or a
reflected-origin scheme. If possible, terminate CORS at a reverse
proxy and leave cors_origins=None on the Bindu app.
Tracking: (no issue yet)
Severity: medium (DoS)
Summary: The A2A endpoint, the scheduler, and ManifestWorker
all run without per-caller quotas or global concurrency caps. A
single authenticated DID can fire message/send in a loop and
exhaust the scheduler queue, storage writes, and memory (tasks are
kept hot for fast lookup). Request-body size is also uncapped on
the Bindu app (Starlette default, no explicit limit). Nothing in
bindu/server/applications.py or
bindu/server/endpoints/a2a_protocol.py imposes rate limits,
per-caller task caps, or a worker-pool semaphore.
Workaround: Deploy behind a reverse proxy (nginx, Cloudflare,
API Gateway) that enforces request-rate and body-size limits per
client IP or DID. Operators running Bindu directly on the public
internet are currently exposed. The fix is per-DID quotas enforced
at the TaskManager.send_message level plus an explicit body-size
limit on the Starlette app.
Tracking: (no issue yet) (shape-equivalent to the gateway's
no-rate-limit-cors-body-size-limit entry)
Severity: medium (revenue / fairness)
Scenario. Carol pays your agent 1 USDC on Base mainnet. The network is congested — block confirmation runs ~25s. Your facilitator's
/settleendpoint waits 10s and returns a timeout failure. Bindu (settle-first) sees the failure, marks the taskfailed, and refuses to run the LLM. ~15 seconds later, the chain confirms the transfer anyway. Carol's wallet shows the debit. Your agent'spay_towallet shows the credit. Your task storage says Carol's requestfailed. Nobody told you.
What's wrong. Settle-first closes the LLM-cost half of #562 but
introduces a quieter failure mode at the boundary between the
facilitator's timeout (typically 5-10s) and Base's confirmation latency
(2-28s under congestion). The facilitator can answer "no" while the
chain ultimately answers "yes" — the result is an orphan payment
with no matching payment-orphaned tag, because the worker's only
signal was success=False from settle. Magnitude is operator-dependent;
under quiet network conditions it's near-zero, but during congestion
spikes (e.g. NFT mints, mainnet flash events) it's the dominant orphan
source.
The fields needed to reconcile are persisted on every failed-settle
task (_settle_payment in
bindu/server/workers/manifest_worker.py
extracts x402_nonce, x402_authorization, x402_network before
attempting settle, so they land in metadata even on exception paths).
What's missing is the periodic worker that uses them — queries the
chain for AuthorizationUsed(from, nonce) on the USDC contract, and
flips matching payment-failed tasks to payment-orphaned-reconciled
once confirmation lands.
Workaround: Periodically scan task.metadata["x402.payment.status"] == "payment-failed" tasks older than ~5 minutes. For each, re-call
the facilitator's /settle with the same payload (idempotent — already-
settled nonces return success with the existing tx hash) OR query
eth_getLogs on the USDC contract for AuthorizationUsed filtered to
(from=x402_authorization.from, nonce=x402_nonce). If found, the
on-chain transfer happened; refund or re-execute as the operator's
policy dictates.
Tracking: (no issue yet) — pairs with
x402-no-auto-refund-for-orphan-payments; reconciliation is the
detection half, auto-refund is the remediation half.
Severity: medium (operator friction)
Summary: Bindu correctly identifies orphan payments today —
_handle_task_failure in
bindu/server/workers/manifest_worker.py
tags task.metadata["x402.payment.status"] = "payment-orphaned" when
manifest.run raises after a successful settle, and persists the full
EIP-3009 fields. But it can't do anything about them. x402 has no
native refund primitive (the protocol is one-shot and one-directional —
payer signs, server settles, transfer is final), and Bindu's
architecture has never managed an outbound wallet: pay_to_address
has only ever been a config string, no private key, no Base RPC
connection, no gas balance. Refunding an orphan today is an entirely
out-of-band ops process — the operator opens their own wallet, reads
x402_authorization.from / .value / x402_network from the task
metadata, and sends a regular USDC transfer back.
Workaround: Manual USDC transfer(to, amount) from the agent's
wallet. Fields are all in task.metadata:
| Field | Use |
|---|---|
x402_authorization.from |
Recipient (the payer) |
x402_authorization.value |
Amount in atomic units (1 USDC = 1_000_000) |
x402_network |
Which chain (eip155:8453 = Base mainnet, eip155:84532 = Base Sepolia) |
x402.payment.receipts[0].transaction |
Original payment tx hash, for audit linkage |
To avoid double-refunding, record the refund tx hash back onto the
task somehow (e.g. set task.metadata["x402.refund.tx_hash"] after
sending) so a future scan can skip already-refunded orphans.
Tracking: (no issue yet) — scoped out in the #562/#565 work as
"build when there's real volume to justify the custody surface."
Pairs with x402-settle-false-negative-silent-orphans (detection
without remediation is half-useful).
No entries yet. Add them when the TS SDK's review pass lands. New
postmortems for fixed SDK bugs go in bugs/sdk/.