Skip to content

fix(billing,security): verified gateway, billing and privacy defects - #52

Merged
oratis merged 12 commits into
mainfrom
fix/verified-findings-2026-08-21
Sep 7, 2026
Merged

oratis merged 12 commits into
mainfrom
fix/verified-findings-2026-08-21

Conversation

@oratis

@oratis oratis commented Aug 21, 2026 •

Copy link
Copy Markdown
Owner

Where this came from

Writing a telemetry plan for TakoAPI (docs/analytics-plan-2026-08-21.md, last commit
here) meant reading the gateway and billing paths closely. That turned up a set of
defects that had nothing to do with telemetry. Each one was then independently verified
against origin/main and, where it concerned production, against read-only gcloud
output. This PR fixes the ones a PR can fix.

Two of the findings were refuted during verification and are deliberately NOT
"fixed" here
— see Not changed, on purpose below. One of them was refuted because
the plan had been drafted on a working tree 18 commits behind origin/main; that
correction is part of the doc commit.

Branch is cut from origin/main = b868319c.

❗ CI on this PR is red for a pre-existing reason — not this PR

The i18n workflow fails at its npm ci step, before check:i18n ever runs:
package.json and package-lock.json are out of sync (@emnapi/*, @swc/helpers).
The same failure is already on main: the push run for b868319c (PR #51,
2026-08-08) failed identically — run 31250086352. Every run of this workflow since
then has failed.
This PR changes neither package.json nor package-lock.json.
npm run check:i18n itself passes — verified locally: All 14 locales match en.json
(618 keys)
. Regenerating the lockfile is a separate, one-line-intent PR; it is
deliberately not bundled here because a ~2500-line lockfile rewrite generated on
macOS/npm 11 cannot be validated against this workflow's linux/node-20 environment
from here, and getting it wrong would also break the Docker build. See operator
action 9.


⚠️ Read this first: changes to billing semantics

Three commits change how and when money moves. Nothing here changes a price — but
they change when a charge is written, whether it can silently not happen, and who gets
rate-limited.

# Change Before After
1 Billed calls block on the debit void meterInvocation(...), response returned without waiting await debitInvocation(...) before the response. Costs one Cloud SQL round-trip on billed calls only
2 A partially delivered SSE stream is now free Charged in full as soon as upstream headers looked OK Charged only if the stream runs to completion over a <400 response
3 One API key = one rate-limit bucket gw:<keyId>:<ip> — 120/60s per IP, and the IP is caller-supplied gw:<keyId> — 120/60s per key, in total. A caller legitimately spread across many IPs will start seeing 429s; ApiKey.rateLimit is the per-key escape hatch (now honoured, see below)
4 PayPal webhook can fail Always answered 200, discarding the outcome Returns 500 when an approved order could not be credited, so PayPal retries. captureAndCredit is idempotent, so a redelivery finding the money already credited answers already + 200
5 ?key=<secret> no longer authenticates cron Authorization header or query string Header only

🔴 #1 and #2 share one operator prerequisite that this PR cannot satisfy: Cloud Run
must be given --no-cpu-throttling.
The service currently has no such annotation, so
CPU is only allocated during request handling. #1 improves matters regardless (the
debit now completes while the request is still open). #2 does not — stream
settlement inherently runs after the response headers are flushed, so moving the charge
to the end of the stream makes it more dependent on CPU staying allocated, not less.
Without that flag, #2 relocates the charge to a less reliable moment. Please apply the
flag before or together with merging this.


What's fixed

Each finding is one commit.

Money

  • F1 — fix(billing): await the credit debit before responding
    The DEBIT ledger entry and balance decrement lived inside a promise that was
    deliberately not awaited, in the same transaction as the Invocation row. On a
    throttled or reclaimed instance both vanish together: a 200 served, nothing charged,
    nothing recorded. billing.ts also swallowed every failure in an empty catch {}.
    Split into debitInvocation (money, awaited) and meterInvocation (telemetry,
    fire-and-forget); empty catch replaced with console.error.

  • F2 — fix(billing): charge SSE streams when the stream ends
    The stream route priced the call off upstream.status before reading a byte, then
    four lines later could return 502 for a missing body — leaving a paid, status-200
    row behind a failed request. Mid-stream breaks, client disconnects and the 120s abort
    were likewise invisible. Now: startInvocation opens the row unpriced before
    relaying, settleInvocation closes that same row at termination (so a stream is
    still one Invocation and one callsCount), pull() gained the try/catch it never
    had, and three errorCode values are introduced — NO_STREAM_BODY,
    STREAM_ABORTED, CLIENT_DISCONNECTED (column already exists, no migration).

  • fix(billing): stop losing the PayPal top-up fee (PAYPAL_ENV=live — real money)
    The idempotency probe looks for a TOPUP row, but gross and fee were two separate
    transactions. A crash in between left the fee unwritten, and the retry then saw the
    TOPUP row, said "already", and never charged it. Merged into one transaction
    (applyTopUp). Separately, three return "failed" paths sat after the PayPal
    capture — non-COMPLETED capture, unattributable payment, non-positive amount — and
    wrote nothing, logged nothing, alerted nothing. All now log with full context.

Security

  • fix(security): stop bucketing gateway rate limits by a spoofable client IP
    extractClientIp reads the first element of X-Forwarded-For, which Cloud Run
    appends to rather than replaces — so it is caller-controlled, and src/proxy.ts
    cannot normalise it (its matcher excludes api|v1|mcp). Varying the header per
    request minted a fresh bucket each time. This matters for money, not just load:
    checkCreditPreflight reads the balance with no lock before the debit, and
    billing.ts documents the limiter as the thing bounding that concurrency.
    RateLimitConfig gains perIp (default true, unchanged for the 11 anonymous
    endpoints); the three /v1 routes pass false.

  • fix(security): accept CRON_SECRET only in the Authorization header
    ?key=<redacted> was accepted, and Cloud Run logs full URLs including query strings
    with 30-day retention — so one hand-rolled curl would have copied the secret into a
    log readable by anyone with logging.viewer, a far wider audience than the Secret
    Manager binding. Nothing used the query form (all six Scheduler jobs send the
    header). Comparison also moved to crypto.timingSafeEqual.

  • F5 — fix(privacy): hash RequestLog client IPs and add a retention sweep
    RequestLog.ip stored raw addresses and nothing ever deleted from the table — the
    only sweep in the codebase targets RateLimitBucket. Now a salted SHA-256 with the
    UTC date mixed in (so correlation is bounded to one day), plus a new
    /api/cron/retention route deleting rows older than 90 days in batches.
    ⚠️ Migration 010 is destructive on purpose: it DROPs ip rather than renaming
    it
    — renaming would preserve the plaintext this change exists to remove, under a
    name claiming it was hashed. Nothing reads the column. Snapshot the rows first if
    they are wanted for an investigation.

  • feat(gateway): honour ApiKey.rateLimit
    scopes, rateLimit and monthlyQuota had existed since the gateway shipped with
    nothing reading any of them. rateLimit is now honoured (null → 120, the previous
    literal, so existing keys are unaffected). scopes and monthlyQuota are marked
    NOT ENFORCED YET in the schema rather than left looking like working guarantees.

Correctness / observability

  • F4 — feat(gateway): emit attributable telemetry for rejected calls
    Correcting the finding as filed: 401/402/429 are not unrecorded — Cloud Run's
    platform log has them. What is missing is attribution: no userId, no apiKeyId, no
    agent, no reason, and 402/429 are indistinguishable there. So this adds a structured
    stdout line (reason, route template, apiKeyId/userId/agentSlug, plus
    requiredUsd/balanceUsd on 402) rather than DB rows — wrapping these routes in
    withRequestLog would have added a write per rejection to an unbounded table.
    No response, status or body changes.

  • F7 — feat(health): record agent health transitions
    Agent.healthStatus is overwritten hourly and there is no history model anywhere, so
    "since when has this agent been down" had no answer. New AgentHealthCheck written
    only on a status flip, keeping the table proportional to flips rather than
    (agents × hours). HealthSummary gains transitions; the cron route now logs the
    summary, which was previously returned to Cloud Scheduler and discarded.

  • F6 — docs(content): stop presenting scraped ClawSkills counts as live install data
    Skill.downloads is scraped from clawskills.sh and refreshed nightly; nothing here
    ever increments it. The trending page nonetheless called it, in all 15 locales, "the
    most-downloaded … right now — a live leaderboard of what coding agents are
    installing" — not live, not now, and not installs. Copy corrected in all 15 locales
    (values only; check:i18n still reports 618 keys matching) and the column
    documented in the schema.

Docs

  • docs(analytics): add the telemetry plan, with its refuted claims corrected
    Also redacts the local filesystem path and the other product's name that the draft
    carried, since this repo is public.

Not changed, on purpose

  • F3 (cloudbuild.yaml drift) — REFUTED, no code change. PR Salvage: cloudbuild safety + tsx devDependency (May 10 local commits) #51 already fixed that
    file on origin/main; the placeholder strings existed only in a stale local
    checkout. And even the old file would not have "overwritten production secrets" —
    gcloud raises ConfigurationError client-side when a literal collides with an
    existing secretKeyRef, so the deploy step would have failed without writing
    anything. Corrected in the plan doc.
  • F8 (prisma/sql preview feature, DELETE … LIMIT) — REFUTED, no code change.
    Neither trap is present. Both were real constraints for the new retention sweep
    though, which is why it uses ctid IN (SELECT … LIMIT n).

Deliberately deferred (reasons, not oversights)

  • User.apiKey stored in plaintext. Real, and it is a full admin path via
    requireAdmin. Not fixed here because it needs a credential migration: five lookup
    sites, a schema change, a backfill on the live DB, and re-issuing every existing key.
    Hashing only new keys would silently break every current one. Needs its own PR plus
    an operator step.
  • Renaming Skill.downloads → externalDownloads. The cleaner fix, but a column
    rename across ~15 call sites with a manual gcloud run deploy and no CI build means
    the old revision would query a dropped column during rollout. Not worth bundling with
    billing changes.
  • ApiKey.scopes / monthlyQuota enforcement. Needs product decisions (scope
    taxonomy, month boundary, behaviour at the cap).
  • RequestLog.userAgent is still stored in full.
  • PAYMENT.CAPTURE.COMPLETED webhook subscriber. A PENDING capture that settles
    later is now logged but still not credited automatically; adding this needs a new
    subscription configured on the PayPal side too.

🔧 Operator actions required (this PR cannot do these)

  1. --no-cpu-throttling on Cloud Run — blocking for F2, strongly wanted for F1.
    Without it, moving stream billing to stream-end makes the charge less reliable.
    Changes the billing model (always-on CPU costs more), so it needs a human decision.
    cloudbuild.yaml uses --update-* and will not clobber it.
  2. Apply migrations 010 and 011 (prisma migrate deploy). 010 drops
    RequestLog.ip and its data
    — snapshot first if it is wanted.
  3. Create the TAKO_IP_SALT secret and bind it to the service. Until it exists,
    ipHash is written as null (deliberately — an unsalted hash of a 32-bit address
    space is trivially reversible).
  4. Create a Cloud Scheduler job for /api/cron/retention with
    Authorization: Bearer <redacted>, same shape as takoapi-health. Until then the
    route is inert and RequestLog keeps growing.
  5. Purge the existing plaintext IP backlog — item 2 removes the column, which
    handles it; if a snapshot is taken first, delete the snapshot on a schedule.
  6. Rotate CRON_SECRET if it was ever passed as ?key= in a shell, CI job or
    browser. Check Cloud Run logs for /api/cron/* URLs carrying a query string.
  7. Verify no automation used ?key= before merging — it stops working.
  8. Plan the User.apiKey credential migration (see above); it is the weakest
    credential in the system and it grants admin.
  9. Regenerate package-lock.json — CI has been broken on main since 2026-08-08.
    npm ci fails on @emnapi/* and @swc/helpers desync, on GitHub Actions
    (linux/node-20) and locally alike, so the i18n workflow has not actually run
    check:i18n on any commit since b868319c. Needs npm install on a machine whose
    platform matches CI, then a check that both npm ci and the Docker build still
    work. Pre-dates this PR; deliberately kept out of it.

Verification

No test framework exists in this repo, so:

Check Result
npx tsc --noEmit (whole project) ✅ exit 0
npm run build (next build) ✅ exit 0, compiled successfully; /api/cron/retention registers as dynamic
npx eslint on all 14 changed .ts files ✅ exit 0
npm run check:i18n (the only CI-enforced check) ✅ All 14 locales match en.json (618 keys)
npx prisma validate ✅ valid

npx eslint src/ reports 18 pre-existing errors, all react-hooks/set-state-in-effect
in admin/dashboard pages not touched by this PR (verified against the changed-file
list). They exist on origin/main unchanged.

Secrets: git diff origin/main was read through and grepped for
password|secret|token|private_key|BEGIN … PRIVATE|api[_-]?key|passwd|credential.
Every hit is a variable name, an env-var name, or prose. No literal credential values
are added. Secret values are referred to as <redacted> throughout.

🤖 Generated with Claude Code

oratis and others added 12 commits August 21, 2026 12:13
The gateway charged callers with `void meterInvocation(...)` — a promise
deliberately not awaited — and the DEBIT ledger entry plus the balance
decrement live inside it, in the same transaction as the Invocation row.

Cloud Run allocates CPU per request by default (the `takoapi` service has
no `--no-cpu-throttling` and no min-instances), so once the response is
flushed a still-pending promise is not guaranteed to run. When the instance
is throttled or reclaimed the transaction is cut short and the Invocation
row and the charge disappear together: a 200 was served, the caller paid
nothing, and nothing recorded that it happened. `billing.ts` also swallowed
every failure in an empty `catch {}`, so even the errors that *were* thrown
left no trace.

- split the transaction body out as `writeInvocation`, and expose two
  intents on top of it: `debitInvocation` (money — must be awaited) and
  `meterInvocation` (telemetry — safe to fire-and-forget). A billed input
  handed to `meterInvocation` is delegated to `debitInvocation`, so a
  missed call site still charges correctly.
- replace the empty `catch {}` with `console.error`, so a debit that fails
  is visible in Cloud Logging instead of vanishing silently. Neither
  function throws: a billing failure must not turn a served call into a 500.
- `/v1/agents/[slug]/message` and `/v1/chat/completions` now `await` the
  debit before returning when `billedUsd > 0`; free and failed calls stay
  fire-and-forget. Cost: one extra Cloud SQL round-trip (unix socket,
  single-digit ms) on billed calls only.

BEHAVIOUR CHANGE: billed calls now block on the debit before the response
is returned.

NOTE: this only closes the window it can close from inside the process.
The stream route is handled separately, and both fixes depend on Cloud Run
being given `--no-cpu-throttling` — an operator action, see the PR body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…der (F2)

The stream route priced a call off `upstream.status` the moment the fetch
promise resolved — before a single SSE byte had been read — and metered it
immediately. Four lines later came:

    if (!upstream.body) return 502 "Agent did not return a stream"

so an upstream that answered 200 with no body produced a 502 for the caller
and a paid, status-200 Invocation row on our side. The same root cause hid
every other mid-stream failure: an upstream that broke halfway, a client
that hung up, and the 120s abort all left the early "success" row untouched,
so the stream route's success rate was structurally overstated and could not
be compared with the non-streaming routes, which only price after
`await res.json()`.

Billing now follows the stream's actual outcome:

- no body → `NO_STREAM_BODY`, status 502, billed 0, then the 502.
- otherwise `startInvocation` opens the row unpriced *before* relaying, so a
  stream that dies mid-flight still counts in the denominator.
- `settleInvocation` closes that same row when the stream terminates —
  updating it rather than inserting a second one, so a stream remains one
  Invocation and one callsCount increment. Only a stream that ran to
  completion over a <400 response is charged.
- `pull()` is wrapped in try/catch (it had none): a mid-stream break or the
  TIMEOUT_MS abort settles as `STREAM_ABORTED`, and `cancel()` settles as
  `CLIENT_DISCONNECTED`. Both bill 0, and a `settled` guard keeps the
  terminal write exactly-once.

BEHAVIOUR CHANGE, two of them:
1. a partially delivered stream is now free — previously it was charged in
   full as soon as the headers looked fine.
2. `Invocation.errorCode` gains three values on this route:
   NO_STREAM_BODY / STREAM_ABORTED / CLIENT_DISCONNECTED. No migration —
   the column already exists.

NOTE: settlement runs after the response headers are flushed, which makes
this route *more* dependent on Cloud Run keeping CPU allocated than F1 is.
Without `--no-cpu-throttling` this moves the charge to a less reliable
moment rather than a safer one — the two changes must land together with
that operator action. See the PR body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt IP

`checkRateLimit` built every bucket as `${config.key}:${extractClientIp(req)}`,
and the three gateway routes pass `key = gw:<apiKeyId>`. The effective bucket
was therefore `gw:<keyId>:<ip>` — one 120/60s allowance *per IP*, not per key.

`extractClientIp` reads the first element of X-Forwarded-For. Cloud Run and the
GCLB append the real client address to any inbound XFF, so that first element is
supplied by the caller; `src/proxy.ts` cannot normalise it because its matcher
excludes `api|v1|mcp` entirely. A caller who varies the header per request gets
a fresh bucket every time, so the per-key limit did not exist.

That limit is load-bearing for money, not just for load: `checkCreditPreflight`
reads the balance and the debit lands later, with no lock in between, and
billing.ts documents the limiter as the thing that bounds that concurrency
("The shared rate limiter bounds that concurrency"). With the limiter bypassable,
concurrent calls can each pass the same pre-flight read and drive a balance
arbitrarily negative.

- `RateLimitConfig` gains `perIp?: boolean` (default true — unchanged behaviour
  for the 11 anonymous endpoints, where the IP is the only identifier available).
- the three `/v1` routes pass `perIp: false`, so an authenticated caller gets one
  bucket per API key regardless of what they claim their IP is.
- documented on `extractClientIp` that the value is caller-controlled and must
  never be used as identity, authorisation input, or evidence.

BEHAVIOUR CHANGE: a single API key is now limited to 120 requests/60s in total
across all source IPs. A legitimate caller spread over many IPs that previously
got 120/min *each* will start seeing 429s. `ApiKey.rateLimit` (wired up in a
following commit) is the per-key escape hatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`ApiKey` has carried `scopes`, `rateLimit` and `monthlyQuota` since the gateway
shipped, and nothing ever read any of them: all 29 `rateLimit` matches in src/
are imports of the limiter module, none is `keyRecord.rateLimit`. Every key got
the literal `max: 120`, so tightening one abusive key meant changing the constant
for everybody — and the schema advertised a per-key control that did not exist.

- `gatewayRateLimit(key)` in src/lib/apikey.ts returns `ApiKey.rateLimit` when it
  is a positive number, else `DEFAULT_GATEWAY_RATE_LIMIT` (120, the previous
  literal — so keys with a null override behave exactly as before).
- the three `/v1` routes use it.
- `scopes` and `monthlyQuota` are marked `NOT ENFORCED YET` in schema.prisma so
  the columns stop reading as working guarantees. Wiring them needs product
  decisions (scope taxonomy, month boundary, what happens at the cap) that do
  not belong in this PR — flagged for follow-up instead.

Schema change is comments only — no migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re failures

PAYPAL_ENV=live and TAKO_TOPUP_FEE_PCT=5 on the running revision, so this path
handles real money. Three ways it could take a payment and not account for it:

1. The fee could be skipped permanently. `captureAndCredit`'s idempotency probe
   looks only for `type: "TOPUP"` with the order id, but the gross and the fee
   were two independent `grantCredit` transactions. Crash between them (instance
   reclaimed, DB blip) and the gross was credited, the fee was not — then the
   retry found the TOPUP row, returned "already", and the fee was never charged.
   Now `applyTopUp` writes both ledger entries and the net balance change in one
   transaction, so "a TOPUP row exists" honestly means "fully settled".

2. Three `return "failed"`s sat *after* `captureOrder` — i.e. after PayPal may
   already have taken the buyer's money — for a non-COMPLETED capture (a PENDING
   eCheck, say), an unattributable payment, and a non-positive amount. None wrote
   a ledger row, logged, or alerted: money in, no credit, nothing to reconcile
   against. All of them now log the order id, capture id, user and amount, and
   the crediting call itself is wrapped so a failed write is reported the same way.

3. The webhook discarded `captureAndCredit`'s return value and answered 200
   unconditionally, which told PayPal never to redeliver — throwing away the last
   chance to recover exactly the cases in (2).

BEHAVIOUR CHANGE: the webhook now returns 500 when an approved order could not be
credited, so PayPal retries it. `captureAndCredit` is idempotent, so a redelivery
that finds the money already credited reports "already" and returns 200. A
genuinely unrecoverable order will be retried until PayPal gives up (~3 days) and
show up as repeated failures in the webhook dashboard — which is the intent: a
visible failure beats a silent one. Successful deliveries now echo the outcome in
the response body.

NOT fixed here: there is still no `PAYMENT.CAPTURE.COMPLETED` subscriber, so a
capture that settles later (case 2's PENDING eCheck) is logged but never credited
automatically. That needs a new webhook subscription configured on the PayPal
side as well as code — flagged for follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`isAuthorizedCron` also accepted `?key=<secret>`. Cloud Run's request log stores
the full request URL, query string included, in the _Default bucket with 30-day
retention — so a single `curl ".../api/cron/health?key=$CRON_SECRET"`, the most
natural way to hand-test one of these endpoints, would have written the secret
into a log readable by anyone holding `logging.viewer`. That is a much wider
audience than the Secret Manager binding the value is delivered through, and the
secret guards five cron routes, one of which (weekly-digest) sends email.

Nothing in production uses the query form: all six Cloud Scheduler jobs send
`Authorization`, and no doc or script references `?key=`. So the query path was
pure downside — a way to leak the secret, with nothing depending on it.

- header-only, and `crypto.timingSafeEqual` in place of `===` (cheap, and the
  right habit for a secret comparison even where the side channel is not
  practically exploitable across the public internet).

BEHAVIOUR CHANGE: `?key=<secret>` no longer authenticates. Anything hand-rolled
against it must move to `-H "Authorization: Bearer <secret>"`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
401 / 402 / 429 are all returned before `meterInvocation` runs, and neither the
three `/v1` routes nor `/mcp` go through `withRequestLog` — so a rejected call
left no Invocation row and no RequestLog row. Any error or success rate computed
from the Invocation table is missing precisely the calls the gateway turned away:
the denominator excludes our own refusals.

Correcting the premise this was filed under: these responses are *not* invisible
today. Cloud Run's platform request log records each one with timestamp, status
and URL, retained 30 days in the _Default bucket. The real gap is attribution —
that log has no userId, no apiKeyId, no agent, no reason, and 402 and 429 are
indistinguishable in it beyond the status code, so none of it joins to Invocation.

So this adds attribution rather than existence: `logGatewayRejection` writes one
structured line that Cloud Run parses into jsonPayload, carrying `reason`
(unauthenticated | rate_limited | insufficient_credit), the route *template*
(never an interpolated path — keeps log cardinality bounded), and, where known,
apiKeyId / userId / agentSlug. The 402 branch also carries `requiredUsd` and
`balanceUsd`, which the credit gate has already computed and which are the only
quantification of demand lost to an empty balance.

Deliberately stdout, not the DB: wrapping these routes in `withRequestLog` would
add a write per rejection to a table that has no retention policy and, until the
next commit, stores plaintext IPs — i.e. it would make an unbounded-growth and a
privacy problem worse in order to fix an observability one. Logging costs nothing
per row and is queryable in Logs Explorer today.

No behaviour change: responses, statuses and bodies are identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`RequestLog.ip` stored the raw client address, and nothing ever deleted from the
table. The only sweep in the codebase, `maybeClean()` in ratelimit.ts, targets
`RateLimitBucket`; no cron route, script or Scheduler job touched RequestLog. So
plaintext personal data accumulated indefinitely on a db-f1-micro with a 10 GB
disk, with no expiry and no purge path.

The stored value was not even dependable: it is the first element of a
caller-supplied X-Forwarded-For (Cloud Run appends the real address rather than
replacing the header), so it was a privacy liability that could not serve as
evidence either — bad on both counts, which is why hashing alone is not the whole
fix here.

- `hashClientIp` writes a salted SHA-256 truncated to 128 bits, with the UTC date
  mixed in, so the same address hashes differently each day. That keeps the one
  question the column was good for ("same source?") within a day and drops
  cross-day correlation without extra deletion machinery.
- No salt, no value: with TAKO_IP_SALT unset the column is left null and the
  process warns once. An unsalted digest over a 32-bit address space is brute-
  forceable in seconds, so a reversible hash would be worse than nothing.
- New `/api/cron/retention` route, guarded by `isAuthorizedCron`, deletes rows
  older than 90 days in batches of 5k, capped at 20 batches per run so it cannot
  overrun `maxDuration`. It reports `exhausted` so a backlog that outlives one run
  is visible instead of looking like a clean sweep. PostgreSQL's DELETE takes no
  LIMIT, so the batching goes through `ctid IN (SELECT ... LIMIT n)` — the exact
  trap F8 flagged for whoever implemented this.

MIGRATION 010 IS DESTRUCTIVE ON PURPOSE: it DROPs "ip" instead of renaming it.
Renaming would keep the plaintext addresses this change exists to remove, under a
name claiming they were hashed. Nothing reads the column (no `.ip` reader in
src/), so it is code-safe — but snapshot the rows first if they are wanted for an
investigation, because after `prisma migrate deploy` they are gone.

Two operator prerequisites, neither doable from a PR: create the TAKO_IP_SALT
secret and bind it to the service, and create the Cloud Scheduler job that calls
this route. Until the salt exists ipHash is null; until the job exists the route
is inert. Both are in the PR body.

NOT fixed here: `userAgent` is still stored in full (truncated to 500 chars).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…est value (F7)

`Agent.healthStatus` / `healthCheckedAt` are the only health state there is, and
the hourly cron overwrites them in place. There is no history model anywhere in
the schema — grep for health in schema.prisma returns exactly those two lines. So
"this agent is down right now" was answerable and "since when", "for how long",
and "what was its availability last week" were not, for any agent, ever.

- new `AgentHealthCheck` (agentId, status, previousStatus, checkedAt) + migration 011.
- `runHealthChecks` now selects `healthStatus` alongside the probe targets and
  appends a row only when the probe disagrees with the stored value. Writing every
  probe would add (agents x 24) rows a day forever on a 10 GB shared-core instance
  to answer the same questions; recording flips keeps the table proportional to how
  often agents actually change, and makes "when did it go down" a single lookup —
  the `checkedAt` of the newest row with status='down'.
- the agent update and the history insert go in one transaction, so the stored
  status and its history cannot disagree. The existing per-agent error isolation is
  kept: a failed write increments `writeErrors` (and backs out its `transitions`
  count) rather than aborting the batch.
- `HealthSummary` gains `transitions`, and the cron route logs the summary as one
  structured line. It was previously returned to Cloud Scheduler, which discards
  response bodies — so even the per-run totals were being thrown away.

No behaviour change to what the endpoint returns, beyond the added field.

FOLLOW-UP not done here: this table needs a retention policy of its own. The
90-day sweep added for RequestLog covers only that table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…all data (F6)

`Skill.downloads` is a number scraped off each skill's clawskills.sh page
(scrape-clawskills.ts takes `texts[4]` by DOM position; daily-sync.ts overwrites
it nightly via the takoapi-daily-sync Scheduler job). Nothing in this repo ever
increments it — install.sh, packages/takoapi-install and takoapi_skill/ report
nothing back — so it carries no information about installs through TakoAPI. It is
also pre-rounded at the source, where "1.2k" becomes 1200.

The trending page nonetheless described it, in all 15 locales, as "the
most-downloaded ... right now — a live leaderboard of what coding agents are
installing". Three claims, none true: it is a T-1 snapshot, not live; it is not
"right now"; and it is not what agents installed, here or anywhere we can see.

- `metaDescription` and `ogDescription` in all 15 locales now attribute the
  numbers to clawskills.sh and say "daily" instead of "live". Values only — no key
  added, renamed or removed, so `check:i18n` still reports 618 keys across 14
  locales matching en.json.
- `Skill.downloads` gets a schema comment stating where the number comes from and
  that it must not be summed into a site metric — the admin panel does exactly
  that today (`_sum: { downloads: true }` surfaced as `totalDownloads`).

Comment-only schema change; no migration.

DELIBERATELY NOT DONE: renaming the column to `externalDownloads`. It is the
cleaner fix and it is what the finding recommends, but it means a column rename
across ~15 call sites plus a migration, and deploys here are a manual
`gcloud run deploy` with no CI build behind them — during the rollout window the
old revision would be querying a column that no longer exists. Not worth taking
that risk inside a batch of billing changes. Flagged for a PR of its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ected

This plan is what prompted the verification pass the rest of this PR came out of.
It was drafted against a working tree 18 commits behind origin/main, so before
committing it its claims were re-checked against origin/main = b868319 and,
where they concerned production, against read-only gcloud output.

Corrected before landing:

- §0.1's "cloudbuild.yaml has drifted from production; running it once would
  overwrite the production secrets with placeholders" — REFUTED, and it was
  listed as a *foundation* of the plan. Wrong twice over: PR #51 rewrote that
  file on origin/main to use --update-env-vars + --update-secrets, and even the
  old file would not have overwritten anything, because gcloud raises
  ConfigurationError client-side when a literal collides with an existing
  secretKeyRef. The placeholder strings existed only in the stale local tree.
  Struck through in the table with the full explanation below it, and §2.3's
  warning block, §8's secret note, the Phase 0 blocker and the file list all
  rewritten. The conclusion "editing cloudbuild.yaml does not change production"
  survives — for the simpler reason that no trigger runs it.
- H3's "401/402/429 leave no record at all" — narrowed. The platform request log
  does record them (timestamp/status/URL, 30-day retention); what is missing is
  attribution. The event is still worth building; its value proposition changes
  from "from nothing to something" to "from unattributable to attributable".
- Two §0.4 open questions closed with measured values: _Default retention is 30
  days, and Cloud Run has an empty cpu-throttling annotation (i.e. throttled), no
  minScale, maxScale=20, containerConcurrency=80.

Added §0.6, a table of the statements the rest of this PR has already made
obsolete, so a reader is not misled by prose describing the pre-fix code.

Also redacted for a public repo: the local filesystem path to the source
blueprint, and the other product's name in the comparisons — neither belongs in
a public MIT repo. Paths in the doc are now repo-relative.

Verified against: origin/main b868319 (2026-08-08).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both new migrations carried no instructions for how they actually reach the
production database, and the PR description said `prisma migrate deploy` —
which docs/agent-marketplace/HANDOFF.md §5/§6 explicitly rules out for this
repo: the `0_init` baseline directory sorts after every `0NN_` directory
('1' < '_'), so deploy would replay the whole history and end by re-running
the baseline, and the production `_prisma_migrations` table is documented as
an unreliable record of what is already applied. The documented procedure is
`prisma db execute --file <migration.sql>`, one file at a time.

Also records the deploy ordering each one needs, which differs:

- 010 is order-insensitive. `logRequest` catches its own write failure and
  never throws, so whichever side is ahead only costs RequestLog rows.
- 011 must be applied BEFORE the new revision. `runHealthChecks` inserts the
  history row in the same transaction as the Agent update, so if the code
  ships first, every status flip in the gap rolls the whole transaction back:
  the flip is lost AND `Agent.healthStatus` is left stale, counted only as a
  writeError.

Comments only — no schema or statement changes. Both files were already
idempotent (IF EXISTS / IF NOT EXISTS), which is what makes the one-file-at-a-
time procedure safe to re-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
oratis added a commit that referenced this pull request Sep 7, 2026
Server-rendered catalog, closed developer funnel, visible pipeline failures. Carries #52 (gateway/billing/privacy fixes) as ancestor commits, plus the fixes from a second 10-dimension adversarial review.
@oratis
oratis merged commit d0b45a4 into main Sep 7, 2026
1 check failed
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