Skip to content

fix(server): budget counters hold their limit per key, count in a registered cache, and keep the in-process fallback bounded - #29345

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-q99u6n
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-q99u6n

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Refs #29053

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:把 fix(plugin-auth): a rate limit admits no more than its limit inside one process, and the auth counters count in a registered cache service #29216 给 plugin-auth 定下的计数规则推广到 server 车道的其余预算计数器:runtime 的入站限流与端点限流(令牌桶)同一个键的更新在进程内串行;runtime、短信每日配额、notify 节点按地址的邮件限额都按同一规则解析 cache 服务(值注册、工厂注册都能拿到,内核自带的 degraded 内存替身不算共享缓存);core 的内存 cache 兜底在写入时清掉已过期的键,不必等再读一次。
  • 为什么改:按维护者裁决「A+C 同意」的 C 半,规则是:每个预算计数器在并发请求下守住限额(同一个键的更新进程内串行),注册了共享 cache 时计在其中(ADR-0069 D2),进程内兜底保持有界。
  • 风险与回滚:同一个键的更新会排队,不同键互不影响;没有 cache 插件时 runtime 的限流桶改走有界的进程内兜底,并照旧告警一次。跨节点仍没有原子自增(契约在 spec,本 PR 不动)。回滚即撤销本 PR。
  • 先看:packages/runtime/src/security/inbound-rate-limit.ts(SharedTokenBucketLimiter.consume)、packages/runtime/src/security/kernel-cache.ts、packages/core/src/fallbacks/memory-cache.ts 的写入清扫。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/security/inbound-rate-limit-concurrency.test.ts src/security/kernel-cache.test.ts src/dispatcher-plugin.rate-limit-cache.integration.test.ts,预期 3 个测试文件、15 个用例全部通过。
  • 需要你做的:无,席位落地。

What changes

The rule, for every budget counter in the server lane: every budget counter holds its limit under concurrent requests (updates to one key are serialised in-process), counts in the registered shared cache when one is registered (ADR-0069 D2), and its in-process fallback stays bounded. PR #29216 applied it to @objectstack/plugin-auth; this applies it to the rest.

  • @objectstack/runtime token buckets. SharedTokenBucketLimiter.consume runs its read, refill, spend and write for one key through serializePerKey (new, package-local src/security/per-key-serial.ts). That covers the server-level inbound limiter (server.security.rateLimit) and every declared endpoint rateLimit, which share the class. Other keys run concurrently.
  • Cache resolution. The dispatcher's inbound and endpoint limiters, the daily SMS send quota (@objectstack/service-sms) and the notify node's per-address email-field limit (@objectstack/service-automation) resolve the cache service through a package-local resolveKernelCache (src/security/kernel-cache.ts, src/kernel-cache.ts, src/builtin/kernel-cache.ts): a value registration and a factory registration both answer; the kernel's own in-memory stand-in, which self-describes degraded (ADR-0076 D12), does not, so those counters keep the bounded per-process fallback and its one-shot warning.
  • @objectstack/core in-memory cache fallback. Expired entries are evicted on write once the map reaches a sweep mark (twice the entries left after the previous sweep, at least 1024), so the map holds at most about twice its live entries without a key having to be read again. No live entry (no TTL, or a TTL still running) is evicted; the ICacheService contract is unchanged.
  • Changeset: .changeset/29053-server-budget-counters.md, patch for @objectstack/runtime, @objectstack/service-sms, @objectstack/service-automation, @objectstack/core.

Local copies, by the seat's decision. per-key-serial.ts and kernel-cache.ts are copies of the two internal plugin-auth helpers #29216 added (same rule, same code), one per package that needs them. Re-exporting them from plugin-auth's published ./rate-limit-storage entry would widen that entry, so they stay internal and are not re-exported from any package.

Why

ADR-0069 D2 promises a budget counted once across nodes when a shared cache is registered, and a counter's limit is only a limit if it holds when requests for one key arrive together. #29216 measured both halves for the auth counters; the server lane's other counters follow the same rule.

Tests

  • packages/runtime/src/security/inbound-rate-limit-concurrency.test.ts (new, 6 cases): concurrent consumes of one key never exceed the budget, in a cache whose calls each cross a macrotask, in the per-process fallback through the middleware, and for a declared endpoint budget; serialisation is per key; a failing update releases the next; nothing is left in flight once the concurrent updates settle.
  • packages/runtime/src/security/kernel-cache.test.ts (new, 7 cases), against real ObjectKernel and LiteKernel contexts: factory, value, none, the degraded stand-in, a non-store value, and a bucket counted through it lands in the factory-registered cache.
  • packages/runtime/src/dispatcher-plugin.rate-limit-cache.integration.test.ts (new, 2 cases), the composed dispatcher on a real ObjectKernel over a socket: with a factory-registered cache the caller's bucket lives in it and the budget holds; with only the stand-in, the stand-in stays empty and the budget still holds.
  • packages/services/service-sms/src/sms-daily-quota-cache.test.ts (new, 2 cases) and packages/services/service-automation/src/builtin/notify-email-field-cache.integration.test.ts (new, 2 cases), on a real ObjectKernel: with a factory-registered cache the counter lives in it and the limit holds; with only the stand-in, the stand-in stays empty and the limit still holds.
  • packages/core/src/fallbacks/memory-cache-bound.test.ts (new, 3 cases): the fallback stays bounded after many distinct keys expire unread; live entries survive every sweep; an expired entry still reads as absent.
  • Forced test edits: none. scripts/engine-double-contract.pinned.json gains the new notify test's data double (written by check-engine-double-contract.mjs --write).
  • Each pin goes red with its check removed (eight ablations, each restored to the HEAD blob).

Acceptance notes

  • Across nodes, unchanged. ICacheService has no atomic increment, so two nodes sharing one cache still read-modify-write against each other. Adding one is a packages/spec contract change, outside this lane.
  • What the rule costs. Updates of one key wait for each other; other keys are unaffected. The fallback cache's sweep costs O(1) per write amortised: on this shared box, one million writes of distinct keys took the same time as before within noise when all stay live, and less when they expire (the map stays small).
  • The stand-in rule matches fix(plugin-auth): a rate limit admits no more than its limit inside one process, and the auth counters count in a registered cache service #29216's choice: a cache-less ObjectKernel deployment's runtime buckets move from the stand-in to the bounded per-process fallback, which is just as process-local, and the "no shared cache" warning now fires there.

Verification

Final head 0c0c6185 (origin/main 39951d2a merged in edc9e043; the two commits after it touch only test harness files: the notify pin's data double and its ledger row, and service-sms's vitest.config.ts). Each reading names the head it was taken on.

Tests and typecheck
  • On edc9e043, under the verify lock: turbo run build of the four packages and their dependencies, exit 0; pnpm --filter PKG test: @objectstack/core 130 files / 2912 tests passed; @objectstack/service-sms 8 / 109; @objectstack/service-automation 260 / 3337; @objectstack/runtime 518 files, 8346 passed, 19 skipped. pnpm --filter PKG typecheck exit 0 for core, service-sms and service-automation; runtime's first read was a missing optional-driver build (no dist for @objectstack/driver-turso), and after turbo run build --filter='./packages/**' it read exit 0 (check:test-typecheck OK, debt ledger held).
  • On a7dd43d1: the notify pin, 2 passed.
  • On 0c0c6185: @objectstack/service-sms test 8 files / 109 tests passed, typecheck exit 0.

Each pin goes red with its check removed (eight ablations, each restored to the HEAD blob).

Dispatch gates and lint
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 0c0c6185 derived 70 commands (18 paths, 1005 changed lines, under the 3000-line threshold). All 70 run with stdin from /dev/null and the exit captured before any pipe: 70 exit 0. --ran: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero).
  • Two families read red on the earlier head and were fixed in the two commits after it: check:engine-double-contract (the notify pin's data double now routes findOne through assertEngineFindOnePredicate, and the pinned ledger gained its row via --write) and check:test-source-alias (service-sms aliases @objectstack/core and @objectstack/spec/api to source rather than widening the shrink-only registry).
  • eslint --no-inline-config over packages/runtime, packages/core, packages/services/service-sms, packages/services/service-automation on edc9e043: exit 0; over packages/services/service-sms on 0c0c6185: exit 0. The repo-wide run is CI's.
  • test:repo is not run locally (no file of that project changed); it is CI's Test Core.
  • CI check-runs: not read yet at the time of writing.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No schema touched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (eslint over the four touched packages; the repo-wide run is CI's.)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English. (The 维护者速读 section is Chinese by the repo's maintainer rule.)
  • The Before Submitting list in CONTRIBUTING.md is done.

…registered cache

Every budget counter holds its limit under concurrent requests (updates
to one key are serialised in-process), counts in the registered shared
cache when one is registered (ADR-0069 D2), and its in-process fallback
stays bounded.

- runtime: SharedTokenBucketLimiter orders the updates of one key
  (package-local per-key-serial.ts); the dispatcher's inbound and
  endpoint limiters resolve the cache through package-local
  kernel-cache.ts.
- service-sms, service-automation: the SMS day quota and the notify
  node's email-field limit resolve the cache through package-local
  kernel-cache.ts.
- core: the in-memory cache fallback evicts expired entries on write.

Claude-Session: https://claude.ai/code/session_01ADHkmPcNztYJ49AQY8UaeN
Co-authored-by: Claude <noreply@anthropic.com>
…e SMS quota, the notify limit and the memory cache

Claude-Session: https://claude.ai/code/session_01ADHkmPcNztYJ49AQY8UaeN
Co-authored-by: Claude <noreply@anthropic.com>
…tomation and core

Claude-Session: https://claude.ai/code/session_01ADHkmPcNztYJ49AQY8UaeN
Co-authored-by: Claude <noreply@anthropic.com>
…indOne through the engine predicate

Claude-Session: https://claude.ai/code/session_01ADHkmPcNztYJ49AQY8UaeN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/runtime, @objectstack/service-automation, @objectstack/service-sms, touching 14 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-sms/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/production-readiness.mdx (via createDispatcherPlugin (symbol, a top-level function))
  • content/docs/kernel/architecture.mdx (via getService (literal, a string literal in resolveKernelCache))
  • content/docs/kernel/cluster.mdx (via getKernel (literal, a string literal in resolveKernelCache), getService (literal, a string literal in resolveKernelCache))
  • content/docs/kernel/runtime-services/audit-service.mdx (via getService (literal, a string literal in resolveKernelCache))
  • content/docs/kernel/services-checklist.mdx (via getService (literal, a string literal in resolveKernelCache))
  • content/docs/plugins/anatomy.mdx (via getKernel (literal, a string literal in resolveKernelCache), getService (literal, a string literal in resolveKernelCache))
  • content/docs/plugins/development.mdx (via getService (literal, a string literal in resolveKernelCache))
  • content/docs/plugins/packages.mdx (via createDispatcherPlugin (symbol, a top-level function))
  • content/docs/protocol/kernel/index.mdx (via getService (literal, a string literal in resolveKernelCache))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-sms/vitest.config.ts) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 60 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 47718699d6421b44e51a4c497bd8009b7d9fc94f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8b0b833fa8c311ecaf0e1514b597fa74954f98f4 — the merge of head 7789dab0469c6b98fc8d111b3a3aa9f8ee5ebb2f into base 47718699d6421b44e51a4c497bd8009b7d9fc94f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8b0b833fa8c311ecaf0e1514b597fa74954f98f4 && git checkout 8b0b833fa8c311ecaf0e1514b597fa74954f98f4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 47718699d6421b44e51a4c497bd8009b7d9fc94f 7789dab0469c6b98fc8d111b3a3aa9f8ee5ebb2f && git checkout -B drift-repro 47718699d6421b44e51a4c497bd8009b7d9fc94f && git merge --no-ff 7789dab0469c6b98fc8d111b3a3aa9f8ee5ebb2f

node scripts/docs-audit/affected-docs.mjs --json 47718699d6421b44e51a4c497bd8009b7d9fc94f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 47718699d6421b44e51a4c497bd8009b7d9fc94f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

维护者速读

  • 改了什么:把 fix(plugin-auth): a rate limit admits no more than its limit inside one process, and the auth counters count in a registered cache service #29216 给 plugin-auth 定下的计数规则推广到 server 车道其余预算计数器。runtime 入站与端点令牌桶对同一个键的更新在进程内串行。runtime、短信每日配额、notify 节点按地址的邮件限额,在注册了共享 cache 时都计在其中,内核自带的 degraded 内存替身不算。core 的内存 cache 兜底在写入时清掉已过期的键,保持有界。
  • 为什么改:维护者「A+C 同意」的 C 半,规则是:每个预算计数器在并发请求下守住限额,有共享 cache 时计在其中(ADR-0069 D2),进程内兜底有界。
  • 风险与回滚:同一个键的更新排队,不同键互不影响。没有 cache 插件时,runtime 限流桶改走有界的进程内兜底,并告警一次。跨节点仍无原子自增(spec 契约,本 PR 不动)。回滚即撤销本 PR。
  • 先看:packages/runtime/src/security/inbound-rate-limit.ts 的 consume,security/kernel-cache.ts,以及 core/src/fallbacks/memory-cache.ts 的写入清扫。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/security/inbound-rate-limit-concurrency.test.ts src/security/kernel-cache.test.ts src/dispatcher-plugin.rate-limit-cache.integration.test.ts。
  • 需要你做的:无,席位落地。

席位意见:与 diff 相符。帮手按席位裁定在各包本地复制,没有新增导出。替身规则与 #29216 一致(A)。正文只写规则(规则 C)。

席位核对

ACCEPT · domain:server seat 5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 · 2026-11-02T12:36Z · head 0c0c6185 · Refs #29053 (follow-up under ruling C, claim 6577282409)

  1. Shape: draft against main, Refs #29053 (the card is closed; this dispatch's identity is the claim), with no closing keyword. patch changeset for @objectstack/runtime, @objectstack/service-sms, @objectstack/service-automation and @objectstack/core. Clause-②: no, Metadata-protocol: none. Not governed: +962 / -43, 18 files.
  2. Against the order: SharedTokenBucketLimiter.consume runs one key's read → refill → spend → write through a package-local serializePerKey. The runtime, SMS and notify counters resolve cache through a package-local resolveKernelCache, which takes the kernel's async leg with a getService fallback and skips a degraded self-description. The memory-cache fallback sweeps expired entries on write at a doubling mark, and ICacheService is unchanged. The cross-node caveat stays in the limiter's docblock. Read in the diff.
  3. Accepted deviations: package-local copies instead of a new plugin-auth export (the seat's ruling). Four files beyond the first claim, added by claim revision 6577282409: the dispatcher's two resolveCache wirings, their integration pin, and two gate-forced files. Runtime typecheck read after building the optional driver's dist.
  4. Gates: the dev reports 70/70 derived gates exit 0 on the final head (0 NOT-MEASURED). The four suites are green (core 2912, service-sms 109, service-automation 3337, runtime 8346), and typecheck and scoped eslint exit 0. Each pin goes red with its check removed. Check-runs on this head were still running at ACCEPT; the seat readies it only after Lint & Repo Gates and TypeScript Type Check read success and no check is red.
  5. Out of scope: an atomic increment on ICacheService is a packages/spec contract change, already routed with fix(plugin-auth): a rate limit admits no more than its limit inside one process, and the auth counters count in a registered cache service #29216. A note on has() over an expired, unswept entry is not a counter path; noted, not filed.
  6. Landing: pr_ready + automerge_enable through the relay once CI is green.

…tion at its root

The package-root vitest config added for the source aliases sets
disableConsoleIntercept: true literally, as every vitest-running package
must (#10374, check-console-intercept-disarm).

Claude-Session: https://claude.ai/code/session_01ADHkmPcNztYJ49AQY8UaeN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

席位核对

ACCEPT · domain:server seat 5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 · 2026-11-02T12:44Z · head 7789dab0 · Refs #29053 (re-ACCEPT, supersedes 6577318389 on 0c0c6185)

  1. Shape: unchanged from 6577318389. One commit on top, touching only packages/services/service-sms/vitest.config.ts.
  2. Against the order: CI's Console-intercept disarm gate read red on 0c0c6185, because the package's new vitest config spread the shared preset, and the gate reads only a literal disableConsoleIntercept: true at the package root. The config now sets it literally, with the prescribed finding: any late console.* in a vitest worker can fail a fully green suite — the #9371 amplifier is upstream and still live #10374 comment, and keeps the two anchored source aliases. The shared preset holds nothing else (test.disableConsoleIntercept only), so behaviour is identical. Read in the diff.
  3. Accepted deviations: as in 6577318389. The PR body's "Final head" line still names 0c0c6185; this record pins the landed head.
  4. Gates: on 7789dab0 the dev reads check-console-intercept-disarm self-test (15 cases) and scan OK over 80 packages, the service-sms suite at 8 files / 109 tests, and check:test-source-alias, typecheck and eslint each exit 0. The gate had not been derived locally: the dispatch-gates residue ledger holds it out of path derivation for a package-root vitest config. That gap is filed separately for the devx lane. The seat readies the PR only after Lint & Repo Gates and TypeScript Type Check read success on this head and no check is red.
  5. Out of scope: as in 6577318389, plus the derivation gap above.
  6. Landing: pr_ready + automerge_enable through the relay once CI is green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant