diff --git a/docs/captures/README.md b/docs/captures/README.md index 1e4a6fe7..4baa4815 100644 --- a/docs/captures/README.md +++ b/docs/captures/README.md @@ -76,7 +76,7 @@ No prompt text, no tool input, no tool output, no file path. `PreToolUse` record | `opencode/plugin-permission-1.18.20-macos.jsonl` | Whether OpenCode's `permission.asked` / `permission.replied` pair carries a session id that joins to the row `collectors/opencode.py` already keys on. What is distinctive is where the join lives: the resolve event carries the session id inline, so a reader needs nothing but the event itself to close the request it opened. The `copilot/permission-events` row above records the other measured resolve event, and that one joins on `requestId` and takes its session identity from the store path. Carries no cross-harness tally on purpose; sibling probes landed the same day and DEC-2 owns the count. opencode 1.18.20, macOS, four interactive TUI sessions driven through `tmux`, one arm apiece, from a scratch project directory outside this repository, with `XDG_DATA_HOME` redirected so no session reached the user's store or dashboard and `CLAUDE_PLUGIN_ROOT`/`DROID_PLUGIN_ROOT` unset for every child. The recorder is **one plain file** under `.opencode/plugin/`, subscribed to the plugin API's `event` hook: the issue's premise that this needs an npm plugin is wrong, and that matters, because npm is broken on this machine and would otherwise have blocked the probe outright. The gate was raised through `OPENCODE_PERMISSION` in the environment rather than a config file, so nothing was written outside the probe's own tree, and the model was a free OpenCode Zen one, so no credential was configured or used. **Positive on the whole question.** `permission.asked` carries `always`, `id`, `metadata`, `patterns`, `permission`, `sessionID` and `tool`; `permission.replied` carries `sessionID`, `requestID` and `reply`; the `requestID` is the `id` of the ask; and in all three gated arms the `sessionID` is exactly a `session.id` row in `opencode.db` — a verdict taken by re-hashing the store's own rows with the probe's salt, so no id was written down anywhere. That is `input_requested`/`input_resolved` with no dwell, no expiry and no inference. All three `reply` spellings were driven, one arm apiece: `once`, `reject`, and `always`, which raises a second dialog saying it holds only until OpenCode restarts. The gates stood 130.4 s, 75.6 s and 35.7 s, and the fourth arm is the control that makes the negatives below worth anything — same recorder, same prompt, same tool, with bash allowed rather than asked: the tool ran and no permission event was produced. **Negative on every store-only route, each taken while the dialog was on screen.** The `permission` table holds project-scoped saved approvals and stayed at zero rows through all four arms, `Allow always` included; the durable `event` table carries only `session.created`, `session.updated`, `message.updated` and `message.part.updated`; and no `session.status` event is delivered between the ask and the reply in any of the three gated arms — the grant and always arms' whole status sequences are the ungated control's exactly, and the reject arm's differs only by a `retry` — so the status lane never announces the wait and there is no during-gate status reading to poll. The file records that absence as a field rather than leaving it to be inferred from a sequence with no timestamps on it. Nothing at all reaches the plugin between the ask and the reply but a single `session.updated`, and the store barely moves: its counts advance at most once inside the gate window and then hold — two distinct (message, part, event) triples in the grant and always arms, one in the reject arm — and they advance again the moment the answer lands, which is the recorded half of the reader-liveness control. Where inside the window that one advance fell was not kept, and no before-the-ask reading was recorded, so the file lists both as unmeasured rather than reading a freeze into them. Two findings belong elsewhere and are recorded here because this is where they were seen. `session_message`, the table the collector's turn scan reads, is **empty** on 1.18.20 across the nine `session` rows the probe's own store holds — the rows are in `message` and `part`; eleven sessions were driven against that store and why two of them left no `session` row is unexplained and recorded as such — so the `SELECT DISTINCT type` the issue asked for has nothing to answer with, and no OpenCode row can show a turn. And an eight-character session-id prefix does not discriminate here: nine store sessions collapse to three prefixes, so a Claude-shaped truncation would collide. The instrument carries its own control because it turned out to need one: an earlier build read its salt from a module const, got some other string back under OpenCode's loader, and reported every identity verdict as false. The file records the digest of a known literal so a reader can see the hashing behind the verdicts was sound. | | `goose/tool-confirmation-1.47.0-macos.jsonl` | Whether Goose writes a `toolConfirmation` into `messages.content_json`, which is what decides between a nearly free passive needs-input and an honest blank. goose 1.47.0, macOS, six interactive sessions driven through `tmux`, one arm apiece, against a scratch `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and `XDG_STATE_HOME`, which kept every session's configuration, store and session log out of the user's home — `~/.config/goose` and `~/.local/share/goose` are still absent after the run — and against a loopback OpenAI-compatible stand-in handing the session exactly one shell tool call, on the precedent the Gemini and Copilot captures set, since the path under test is client-side. The overrides did not cover every `goose` write: `~/.local/state/goose/logs/cli/` holds six per-invocation CLI logs from runs made without them, five of them empty and one holding a single `CLI command executed` line, five written inside the probe window and one predating it. That directory is the only thing this probe left in the user's home, and it is named here so the next probe knows `XDG_STATE_HOME` does not cover it. `GOOSE_MODE` was `approve`, read back off the session's own `/status` and off `sessions.goose_mode` rather than assumed. **Negative, and the control is inside every arm**: while the confirmation dialog stood — 93 s approved, 169 s denied, 137 s cancelled, each evidenced by the pane — the newest `content_json` held `text` parts and nothing else, no row was added at all, and a sweep of every text column of every table found none of `toolConfirmation`, `actionRequired`, `confirmationId` or `frontendToolRequest`. Answering is what writes: `toolRequest` and `toolResponse` arrive together once the key is pressed, backdated to the turn start, and that is the positive control separating a blank store from a blind reader. Nor is the blank a stale snapshot: the store runs in `wal` mode and every read opened a fresh `mode=ro` connection with `immutable` unset, so a row committed while the gate stood would have been visible to the reader that found none. A **cancel writes nothing at all**, so an abandoned call leaves no trace in either direction. The `actionRequired` variant with its `toolConfirmation` `actionType` is real — it sits in the binary's message-content enum beside `toolRequest` — and the file records that as read in the binary rather than run, because no arm put one on disk. Two readings from the **shipped collector** say what the blank costs: 48.8 s into a standing gate the row reads `working`/`generating…`, and 125.6 s in it reads `idle`/`awaiting your message`, because `sessions.updated_at` is bumped once as the gate opens and then sits frozen — 149 s between the two polled values, a window the file does not tie to one arm, because it runs longer than the 137 s gate the readings beside it name — so a gate past the 90 s working threshold is indistinguishable from a session nobody is using. The hook route was measured in the same rig and does not reach the gate either: with the probe plugin registered, `SessionStart` and `UserPromptSubmit` fire, then nothing for the 83.2 s between the prompt and the answer — the window that holds the standing dialog, and the model's generation before it, since nothing timed the dialog itself in that arm — after which `PreToolUse`, `BeforeShellExecution`, `PostToolUse`, `AfterShellExecution` and `Stop` all land inside 0.71 s. **Positive on identity**: the hook's `session_id` is the whole 10-character `sessions.id` the collector keys on, untruncated, checked by digest against the store row rather than by eye. Three registration arms are what make that negative safe, and they carry a warning of their own — a wrapped `hooks.json` logs `Loaded plugin hooks` with `rule_count` 11, an unparseable one logs `Failed to load plugin hooks; skipping` naming the plugin and the file, and a **flattened one, events hoisted to the top level, the exact shape that voided the first Antigravity capture, logs nothing and registers nothing**, so on Goose a malformed probe is indistinguishable from a harness with no hooks. Discovery is `$HOME`-relative, which is how the probe kept its plugin out of the user's home: the plugin lived under a scratch `HOME`, was named `cargento-probe-goose` rather than `cargento`, passed `goose` as its own harness argument, and posted to no dashboard at all, while the two arms run without it loaded zero rules. `~/.agents/plugins` was absent before this run and is absent after, and that absence is the only thing between Goose and the mis-route into `/api/events/claude` that the containment note describes, since `validate_hook_vocabulary` is keyed by file path and cannot say that Claude's file is also read by Goose. That mis-route is reasoned rather than run — no arm pointed Goose at the shipped file — but the registration arms are what it rests on: `cargento/hooks/hooks.json` carries the single top-level key `hooks`, which is the wrapped shape Goose loads rather than the flattened shape it ignores, and five of its ten event names are among the eleven Goose registered. | | `droid/notification-0.202.0-macos.jsonl` | Whether Droid's `Notification` hook fires with the `notification_type` vocabulary `notifications.py` already implements, and whether `session_id` is what `collectors/droid.py` keys on. droid 0.202.0, macOS, three sessions driven from a scratch directory outside the checkout with `CLAUDE_PLUGIN_ROOT` and `DROID_PLUGIN_ROOT` unset — two of them with the recorder registered, and **every hook execution in the file is from the non-interactive `droid exec` path**, which Droid's own log stamps `droidMode=non-interactive-cli`. The interactive `terminal-ui` launches, driven through tmux, reached the login screen, started no session and fired no hook, so the payload key sets below are properties of `exec` and the interactive path is unmeasured. The recorder was registered through a **project-scope** `/.factory/hooks.json`, so `~/.factory/hooks.json` was never written, and every correctly-wired arm passed `droid` as its harness argument. **The Notification half is arrangement-unreachable rather than negative**, and the file records why by name: every agent turn needs a Factory account, the CLI says so on the interactive path and on `exec` alike, and all three notification types this binary can produce are fired from turn code. Because no turn ran in either mode, the account gate is a sufficient cause rather than a measured sole one — whether `exec` would suppress notification delivery on its own is unmeasured, and the file lists it as such. The control that makes that safe to write down is in the file rather than asserted beside it — the same recorder, registered additively on Claude Code 2.1.241 through `--settings`, recorded a real `Notification` carrying `idle_prompt` 64 s after `Stop`, in the same rig on the same machine on the same day. So the missing Droid record is the account gate and not the instrument. **What did run is the structural trap, measured rather than reasoned about.** A distinctly-named plugin replicating `cargento/`'s exact layout — a `.claude-plugin` manifest directory and a `hooks/hooks.json` nested under a top-level `hooks` key whose commands interpolate `${CLAUDE_PLUGIN_ROOT}` and pass the literal argument `claude` — was installed at project scope from a local marketplace, and Droid loaded it, expanded the variable, and ran the hook with `claude` as its harness argument, twice, before the auth gate. The recorder it reached lives inside the plugin root and makes no network call of any kind, so the demonstration could not itself post to `/api/events/claude`; what would happen if it did is closed in code in the same file, read-only against a synthetic id — `/api/events/droid` is refused as an unknown harness while `/api/events/claude` is **accepted** and truncates a 36-character id to eight, which matches no row the collector keys on the whole 36. `validate_hook_vocabulary` was green for this tree throughout, which is the point: it is keyed by file path and cannot say that a file is also read by Droid. **Positive on identity, on both candidate keys.** `session_id` is a 36-character UUID, the transcript filename stem equals it, and line 1 is a `session_start` whose `id` equals it too, so `sid = meta["session_id"] or basename(fp)[:-6]` takes the first branch and the fallback would agree — recorded both ways because the fallback is the case the question names. Two things are read in the binary and never run, and are labelled that way: the `Notification` payload's key list, and the vocabulary, where this version carries `permission_prompt`, `idle_prompt` and `elicitation_dialog` and **`auth_success` does not appear in the binary at all**, so the four-value list the vendor documents is three here. A finding outside the question rides along because it was found by running: `config.py` points `droid.projects` at `/.factory/projects` and 0.202.0 writes `/.factory/sessions//.jsonl`, so with three real sessions on disk `discover()` returned False and `collect()` returned no rows. The per-record parsing was right; only the root was wrong, and DRC-4331 (2026-09-02) added `sessions` ahead of `projects`, which stays as the fallback. | - +| `droid/billing-limits-0.210.0-macos.jsonl` | What Droid's `/limits` command calls, with which headers, and what comes back; and where the CLI keeps the credential it sends. droid 0.210.0, macOS, the user's own signed-in organization account, two interactive TUI runs driven through `tmux` from a scratch directory, with `FACTORY_API_BASE_URL` pointed at a loopback reverse proxy that forwarded to `api.factory.ai` and logged method, path, request header names, status and the response with every value replaced by its type. No hook exists in this path, so a purpose-built recorder wrote it, as `claude/usage-endpoint-macos.jsonl` did. Two values are kept on the limits route alone, booleans and one enum string, on the reasoning above: they are the vendor's vocabulary and they are why the answer is empty. **The route is pinned and the answer on this account is no windows.** `/limits` is `GET /api/billing/limits` with a bearer authorization and the CLI's `X-Factory-Client`, `X-Client-Version` and `X-Factory-Org-Id` headers, answered 200 with four top-level keys (`usesTokenRateLimitsBilling`, `overagePreference`, `canManageOverage`, `extraUsageAllowed`) and no `limits` object, and the CLI's own panel then read `Unable to fetch credit limits`, because it requires `limits.standard` and this account has `usesTokenRateLimitsBilling` false. The windowed shape the CLI is written against, `limits.standard.{fiveHour,weekly,monthly}.usedPercent`, is read in the binary and never run, and the file labels it so. **The login credential is out of reach by design**: the Keychain item `Factory CLI` holds a 44-byte encryption key under the account `auth-encryption-key-security-cli`, and the token sits encrypted in `~/.factory/auth.v2.loginkeychain` beside a refresh lock, so it is another program's encrypted, self-rotating store; the file names the sibling store names and the `FACTORY_DISABLE_KEYRING` disk fallback from the binary. `FACTORY_API_KEY` is the CLI's other credential route, sent on the same header, and is the route DEC-10 (DRC-4333) chose; whether the limits route accepts a key is unmeasured here. The probe also confirms, by running, that the collector root DRC-4331 added is the one 0.210.0 still writes to. | Not every capture is a payload record. `codex/permission-hook-0.146.0-macos.jsonl` answers a question no payload could, because the payload never arrives: it pairs what each run asked for with the policy it was actually given, which is what turns "the hook did not fire" into "the hook cannot fire, and here is the mechanism". `claude/permission-decision-2.1.238-macos.jsonl` is the same kind of file for the opposite problem: the hook fires every time, so what needed evidencing was that its *return value* is discarded. A run that returns the wrong spelling and a run against a harness that reads no spelling at all look identical, which is why the invalid-value control is in there. Its timing half also corrects a measurement error worth recording: a first attempt timed the gate from the hook's exit log, which is written after the hook sleeps, and so reported a 91s wait that was the instrument rather than the harness. diff --git a/docs/captures/droid/billing-limits-0.210.0-macos.jsonl b/docs/captures/droid/billing-limits-0.210.0-macos.jsonl new file mode 100644 index 00000000..57412d8c --- /dev/null +++ b/docs/captures/droid/billing-limits-0.210.0-macos.jsonl @@ -0,0 +1,2 @@ +{"v": 1, "harness": "droid", "cli_version": "0.210.0", "os": "posix", "_kind": "provenance", "at": "2026-09-02", "issue": "DRC-4073", "question": ["what does Droid's /limits command call, with which headers, and what comes back", "where does the Droid CLI keep the credential it sends, and can Cargento read it"], "arrangement": {"harness": "droid 0.210.0 (auto-updated from 0.202.0 between the first look and this run), Factory installer, macOS arm64", "account": "the user's own signed-in Factory login, an organization account, signed in by the user in the browser flow minutes earlier; no API key set", "cwd": "a scratch directory outside the cargento checkout, so the probe's session landed under its own ~/.factory/sessions//", "env": "CLAUDE_PLUGIN_ROOT and DROID_PLUGIN_ROOT unset; FACTORY_API_BASE_URL pointed at a loopback reverse proxy", "driver": "tmux: launch the interactive TUI, wait for its first API call, send /limits, wait for the limits route, capture the pane, /exit", "recorder": "a purpose-built loopback reverse proxy (Python http.server + urllib) forwarding every request to https://api.factory.ai. It logged method, path with any query keys, request header NAMES, status, and the response JSON with every value replaced by its type. Header values never reached the log; the Authorization value was held in memory for the forward and nowhere else. On the limits route only, booleans and short enum-like strings were kept as values, because they are a vocabulary the vendor picks from and they are the reason the answer looks the way it does", "runs": 2, "runs_agreed": true, "dashboard": "none: no Cargento server was started and nothing was POSTed anywhere"}, "refused_on_purpose": ["every header value", "every number", "the session id in the session routes", "the response bodies of every non-limits route", "anything from the login store"]} +{"harness": "droid", "_kind": "billing_limits_response", "at": "2026-09-02", "endpoint": "GET https://api.factory.ai/api/billing/limits", "how_the_cli_reaches_it": "the /limits slash command; the same request runs before a turn when the CLI decides whether the standard allowance is exhausted (TokenLimitService). Base URL is the CLI's apiBaseUrl, production https://api.factory.ai, overridable with FACTORY_API_BASE_URL, which is how the proxy sat in the path", "request": {"method": "GET", "query_keys": [], "body": null, "header_names": ["accept", "accept-encoding", "authorization", "connection", "host", "user-agent", "x-client-version", "x-factory-client", "x-factory-org-id"], "authorization_scheme": "Bearer, the login session token. The CLI's own FACTORY_API_KEY verification path sends the same scheme with the key, plus X-Factory-Whoami-Extended: true, to GET /api/cli/whoami, so a key rides the same header on every route"}, "response": {"http_status": 200, "top_level_keys": ["canManageOverage", "extraUsageAllowed", "overagePreference", "usesTokenRateLimitsBilling"], "shape": {"usesTokenRateLimitsBilling": "bool=false", "overagePreference": "enum=droidCore", "canManageOverage": "bool=true", "extraUsageAllowed": "bool=false"}, "limits_key_present": false}, "cli_verdict_on_this_response": "Unable to fetch credit limits. Please try again or visit https://app.factory.ai/settings/usage", "why_that_verdict": "the CLI requires response.limits.standard and logs 'TokenLimitService: limits.standard missing from API response' when it is absent; on this account it is absent, and usesTokenRateLimitsBilling is false", "read_in_the_binary_never_run": {"windowed_shape": "limits.standard.fiveHour, limits.standard.weekly, limits.standard.monthly, each with usedPercent; the CLI treats usedPercent >= 100 on any of the three as exhausted. resetAt is present as an identifier near this code and its placement is not pinned", "panel_bucket_labels": ["5-hour", "Weekly", "Monthly"], "panel_tabs": ["Standard", "Droid Core", "Extra Usage"], "other_response_fields_the_cli_reads": ["extraUsageBalanceCents", "overagePreference", "extraUsageAllowed", "canManageOverage"], "vendor_docs": "docs.factory.ai/pricing/individuals: individual plans meter Factory Standard Credits over three independent rolling windows, 5-hour, 7-day and 30-day; the public Analytics API (api.factory.ai/api/v1/analytics, key auth) reports daily consumption through yesterday and no window"}, "credential_store": {"keychain_item": {"service": "Factory CLI", "account": "auth-encryption-key-security-cli", "secret_bytes": 44, "secret_is": "an encryption key, not a token"}, "token_file": {"path": "~/.factory/auth.v2.loginkeychain", "bytes": 2078, "mode": "0600", "content": "not JSON; an encrypted blob the CLI decrypts with the Keychain key"}, "cli_log_source_name": "login-keychain-v2", "sibling_store_names_in_binary": ["auth.v2.keyring", "auth.v2.loginkeychain", "auth.v2.file", "auth.v2.key", "auth.v2.write.lock"], "disk_only_fallback": "FACTORY_DISABLE_KEYRING=1 stores credentials on disk instead, per the CLI's own workaround message", "refresh": "the store carries refresh context and a refresh lock, so the login token is one the CLI rotates itself", "verdict": "not a credential Cargento reads: it is another program's encrypted store and a self-rotating session"}, "api_key_route": {"variable": "FACTORY_API_KEY", "prefix": "fk-", "created_at": "https://app.factory.ai/settings/api-keys", "cli_behaviour": "overrides any stored login session, per the CLI's own log wording", "measured_here": false}, "on_disk": {"per_session_settings_keys_measured": ["tokenUsage", "inclusiveTokenUsage"], "token_usage_keys": ["inputTokens", "outputTokens", "cacheCreationTokens", "cacheReadTokens", "thinkingTokens", "factoryCredits"], "window_on_disk": false}, "startup_routes_seen_through_the_proxy": ["GET /api/cli/whoami", "GET /api/feature-flags", "GET /api/organization/managed-settings", "POST /api/automations/sync", "GET /api/user/session-default-settings", "POST /api/sessions/create", "POST /api/sessions//update-settings", "POST /api/connectors/tools/list", "GET /api/billing/limits"], "what_this_settles": ["the route, method and header set behind /limits", "that on an account with usesTokenRateLimitsBilling=false there is no window to read, and Droid's own panel says so", "that the login credential is out of Cargento's reach by design", "that the windowed response shape remains unmeasured until an account on token-rate-limits billing runs this probe"]} diff --git a/docs/design-usage-quota.md b/docs/design-usage-quota.md index c000905e..ddd5a669 100644 --- a/docs/design-usage-quota.md +++ b/docs/design-usage-quota.md @@ -367,6 +367,21 @@ band entirely, because a sign-in is wrong advice when the harness session may be surfaces in diagnostics as a fixed category word plus an exception type name; no value read from a credential source or a response is ever interpolated. +Droid has no token Cargento may read, and DEC-10 (DRC-4333) settled what it sends instead. The +Droid CLI keeps a 44-byte encryption key in the macOS Keychain, service `Factory CLI`, account +`auth-encryption-key-security-cli`, and the session token encrypted with it in +`~/.factory/auth.v2.loginkeychain`, beside a refresh lock the CLI takes when it rotates the session. +Reading that would mean decrypting another program's store to lift a credential it refreshes +itself, and the never-refresh rule above has nothing to bound in that arrangement, so it is +rejected below. The vendor documents one other route, an API key the user creates and the CLI +accepts through `FACTORY_API_KEY` on the same bearer header. When the reader ships it takes that +variable from Cargento's own process environment, presents it to one endpoint as a bearer token, +and does nothing else with it: not written, not logged, not served, not exchanged. A missing +variable keeps Droid out of the band with no sign-in advice, since the harness may be signed in and +working normally without it. Whether the limits endpoint accepts a key is unmeasured until an +account with windows runs the capture, and SECURITY.md names the endpoint and the variable in the +same PR as the reader. + ## Q-7: A harness can hand its quota over, so nothing needs fetching Antigravity keeps no quota on disk and its stored credential is not usable as a bearer token, so @@ -444,30 +459,30 @@ which is itself one of the states question three has to render rather than hide. | Google | Antigravity | percent per named bucket, the worse of two model families (Q-7) | the user pointed the status line at `/api/usage` and the harness ran inside `window_hours` | | Cursor | Cursor CLI | spend in cents against a monthly billing cycle, macOS only (Q-8) | the Keychain token reads and the disclosure has been answered | | GitHub | Copilot CLI | AI Units consumed, with the entitlement nowhere on the machine (Q-6) | a usage row landed inside `window_hours` | -| Factory | Droid | nothing yet: the store holds a per-session credit count and no window, and the authenticated route is unread | never, so far as anything here knows | +| Factory | Droid | nothing: on the account measured, `GET /api/billing/limits` carries no window, and the store holds a per-session credit count | never yet; an account on token-rate-limits billing would publish three windows nobody here has captured | Two rows in that table are absences of different kinds, and a comparison that treats them alike gets question one wrong. GitHub publishes a numerator and no denominator, which is a measured fact: `entitlement` and `allowance` appear in no file under `~/.copilot`, and Q-6 is the record of -looking. Factory is half looked at, and the provenance of each half is different, so it is -spelled out. Droid 0.202.0 was installed on the machine this work was done against and its store -was read by hand on 2026-09-02, with the key names recorded on DRC-4073 and no capture file yet: -each session's sibling `.settings.json` carried `tokenUsage` and `inclusiveTokenUsage` -objects with token counts and a `factoryCredits` figure, a numerator in Copilot's shape, and nothing -under `~/.factory` resembled a 5 hour, 7 day or 30 day window. Until a capture lands, that is a -reading and not a measured field, and no parser is written against it. The other half is unread -because no Factory account was signed in there, which the CLI's own log states. What the binary's -strings show, read and never run: a `/limits` command, a `FACTORY_API_KEY` variable as one credential -route, and a login flow backed by an embedded keytar store, so a Keychain item on macOS is the -expectation and not an observation. The HTTP route behind `/limits` was not found in those strings. -So "Factory keeps no local quota window" is a reading awaiting its capture, and "Factory publishes -no window at all" is still the absence of one. Every field this document treats as measured -(Copilot's `total_nano_aiu`, Cursor's cents, Anthropic's `limits[]`) came from a live store or a -live response. The Antigravity attempt below is the reason to hold even a searched-for absence -loosely: its local forensics were thorough, and the number it concluded was out of reach ships -today, arriving by the pushed path in Q-7 instead. An absence found by looking can still be -overturned by a route nobody enumerated, so the authenticated route stays unmeasured in writing -rather than written down as empty. +looking. Factory was looked at on 2026-09-02, on the account signed in on the machine this work was +done against, and the record is +[`captures/droid/billing-limits-0.210.0-macos.jsonl`](captures/droid/billing-limits-0.210.0-macos.jsonl). +Droid's `/limits` command is `GET https://api.factory.ai/api/billing/limits`, and on that account +it answered with four top-level fields and no `limits` object, because `usesTokenRateLimitsBilling` +is false there; Droid's own panel then said it was unable to fetch credit limits. So for that +account the vendor publishes no window, which is a finding and not the absence of one, and Cargento +can show nothing the CLI cannot. Individual plans are metered differently, three independent rolling +windows of Factory Standard Credits at 5 hours, 7 days and 30 days, and the CLI is written against +`limits.standard.fiveHour`, `weekly` and `monthly`, each with a `usedPercent`; that shape is read in +the binary and has never been run here, so no parser is written against it. The store holds a +per-session `.settings.json` whose `tokenUsage` object carries token counts and a +`factoryCredits` figure, a numerator in Copilot's shape, read by hand and not yet captured. Every +field this document treats as measured (Copilot's `total_nano_aiu`, Cursor's cents, Anthropic's +`limits[]`) came from a live store or a live response. The Antigravity attempt below is the reason +to hold even a searched-for absence loosely: its local forensics were thorough, and the number it +concluded was out of reach ships today, arriving by the pushed path in Q-7 instead. An absence found +by looking can still be overturned by a route nobody enumerated, so the windowed route stays +unmeasured in writing rather than written down as empty. Anthropic's row also says something the other rows do not, and it decides the unit of comparison. That percentage is the account's, not Claude Code's: SECURITY.md names the authority as "Claude @@ -738,6 +753,18 @@ the per-row rule above. ## Rejected alternatives worth keeping rejected +### Decrypting Droid's login store + +The Droid CLI's stored session is an encrypted file whose key sits in the Keychain, so the token is +one `security find-generic-password` away from being readable in principle: fetch the key, read the +file, reproduce the CLI's decryption. It is not done, for two reasons that do not weaken with a +cleverer implementation. The store belongs to another program and its format is that program's to +change without notice, so a reader here would break silently on the next Droid release and, worse, +could read the wrong bytes and present them as a credential. And the session it protects is one the +CLI rotates under a refresh lock, so Cargento would be lifting a value out from under a process that +is about to replace it, which is the race the never-refresh rule exists to stay out of. The API key +route in Q-5 costs the user one key and has neither problem. + ### Refreshing a lapsed token Claude Code rotates its own OAuth session. A second refresher racing it can invalidate the