Skip to content

feat: instance GET /usage and usage on unlock: daily limits survive a stop (#50, keyring side) - #56

Merged
jerryfane merged 1 commit into
mainfrom
feat/usage-handoff
Oct 14, 2026
Merged

jerryfane merged 1 commit into
mainfrom
feat/usage-handoff

Conversation

@jerryfane

Copy link
Copy Markdown
Contributor

Keyring side of #50 (the Durable Object side lands with the Cloudflare template). Part of #46. Stacked on #54 → #53 (base feat/container-mode); I will retarget it to main after those merge.

What

  • GET /usage (admin API) → {"day", "counts": [{"role", "service", "count"}]}, today's (UTC) daily-limit counts. They are not secret and are not sealed.
  • POST /unlock takes an optional "usage" (what GET /usage returned) and restores it: counts for another day are ignored, and for each role and service the higher count wins, so a restore never gives a role more calls.
  • server.Handler.DailyCounts / RestoreDailyCounts.
  • Docs: the supervisor keeps /usage while the process is stopped, fetches it just before stopping, and keeps the process up while consent links are open; audit goes to stdout by default.

Why not seal the counts into the state

Sealing them would mean a state write and a DO write on every proxied call. A checkpoint the supervisor fetches before stopping the process limits any loss to calls after the last checkpoint, which only happens when a process stops without warning.

Unchanged

keyring serve is not affected: the new methods are only called by the instance admin API.

Tests

  • go test ./... -race (Go 1.24.13): all ok.
  • New: TestDailyLimitSurvivesRestart (instance: limit 2, two calls, restart from sealed state + usage → 429; stale-day usage ignored), TestRestoreDailyCountsNeverGivesMoreCalls (server: lower kept count does not undo calls; full count blocks; next day fresh; other-day restore ignored).
  • 4 hand mutants, all red: restore overwrites instead of max, stale day applied, unlock ignores usage, export empty.

…top (#50)

A supervisor that stops the process (a container going to sleep) keeps
today's daily-limit counts from GET /usage and passes them back to POST
/unlock. Counts of another day are ignored; a restore never lowers a
count. Consent links stay in memory: the supervisor keeps the process up
until they expire. Audit already goes to stdout by default.
@jerryfane

Copy link
Copy Markdown
Contributor Author

Agent: g7-review
Runtime: omp
Job: local-review-review-router-18de8221b4dc9e50-1

Decision: approved
Summary: Delta c08c15c..ca6292b reviewed and executed at ca6292b. Daily-limit restore is max-merge per pair, ignores other-day snapshots, and shares h.mu with take/refund — verified by concurrency probe (200 racing calls vs restores: admitted <= limit, upstream count == admitted). GET /usage sits behind the admin bearer wrapper (401 unauth/wrong token, 409 locked, no secret fields). unlock accepts absent/null/valid usage, 400s malformed without unlocking. Two P3s: unlock serves requests before RestoreDailyCounts runs (calls in the µs window can evade a restored-at-limit count); a restored empty role/service pair is stored and re-exported as cosmetic noise. keyring serve untouched (additive symbols only). No prior findings for PR #56. CGO_ENABLED=1 race build could not run: sandbox denies /usr/include/stdc-predef.h.
Severity: P3

Findings

  • unlock serves proxy traffic before restoring daily counts (P3)
    • detail: commit() ends by starting srv.Serve goroutines; unlock() then calls RestoreDailyCounts. Requests landing between serve-start and the restore are charged against empty counts and can be max-ed away by the restore, so a caller racing unlock can exceed its daily limit by calls that fit the window. Fix: apply the restore inside commit after Swap and before serve, or pass usage into commit.
    • file: internal/instance/instance.go
    • locator: internal/instance/instance.go:499-505 (RestoreDailyCounts after commit) and internal/instance/instance.go:277-278 (commit ends with in.serve)
  • restored empty role/service pair stored and re-exported by GET /usage (P3)
    • detail: RestoreDailyCounts({"","",1}) creates a {"",""} count that DailyCounts re-exports. Verified live. Unmatchable in take() since role/service names come from non-empty URL path segments — cosmetic JSON noise only.
    • file: internal/server/server.go
    • locator: internal/server/server.go:688-692 stores any pair; 661-663 exports n>0

Tests Run

  • CGO_ENABLED=0 go build ./... — clean
  • CGO_ENABLED=0 go vet ./... — clean
  • CGO_ENABLED=0 go test ./... — all 10 packages ok at ca6292b
  • CGO_ENABLED=0 go test ./internal/server/ -run TestRestoreDailyCounts -v — PASS
  • CGO_ENABLED=0 go test ./internal/instance/ -run TestDailyLimitSurvivesRestart -v — PASS
  • Overlay probes in copied tree: TestProbeRestoreSeedsFreshHandler, TestProbeTakeRestoreConcurrency (200-way race, admitted<=20, upstream==admitted), TestProbeRestoreDegenerate, TestProbeRestoreStaleDayOnFresh, TestProbeUsageAuthAndShape (401/401/409/empty-no-secrets), TestProbeUnlockUsageVariants (absent/null/valid/malformed/unknown-field), TestProbeReplayedSnapshotCannotLower — all PASS
  • CGO_ENABLED=1 go test -race — could not run: sandbox denies /usr/include/stdc-predef.h

Raw runtime output was retained in local Gitmoot state and is not posted here.

@jerryfane
jerryfane changed the base branch from feat/container-mode to main October 14, 2026 21:36
@jerryfane

Copy link
Copy Markdown
Contributor Author

Merge gate (keyring seat): level2_background at ca6292b. CI is green on main after the retarget. The review already ran before the merge: g7-review job local-review-review-router-18de8221b4dc9e50-1 approved this exact head, so no post-merge request is needed. Nothing was released.

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