Skip to content

fix(packs): cap view writes at the pack quota; review follow-ups to #437 - #444

Merged
johnnyelwailer merged 5 commits into
mainfrom
fix/pack-store-review-followups
Oct 8, 2026
Merged

johnnyelwailer merged 5 commits into
mainfrom
fix/pack-store-review-followups

Conversation

@johnnyelwailer

Copy link
Copy Markdown
Owner

Follow-ups from the #437 review, in its priority order.

1. MEDIUM: the quota is now a cap for view writes

Before this change the quota was only enforced by the hourly retention pass, and keep collections are never evicted. A view writing a keep + viewWritable collection could therefore grow a pack without bound. On an evictable collection it could fill the quota within the hour and make the pass evict the pack's own documents.

putFromView now checks the pack's live bytes inside the write transaction (SUM(byte_size) over unexpired rows) and refuses with the new QuotaExceeded reason ("The pack's storage quota is full.") when the write would grow the pack past quotaBytes. Rewriting a document in place without growing it is still allowed, so a full pack can still be edited and shrunk. Script writes keep the eviction-based quota from the spec. They can opt in through the new put option capAtQuota.

Test: t3team-packDocumentQuota.test.ts loops putFromView(... doc: "x".repeat(15_000)). It stops after 6 documents in a 100 KB pack with "The pack's storage quota is full.", and rewrites (same size, then smaller) still succeed. It was committed red first: all 20 writes landed. Mutation check: counting the whole new size instead of the growth fails the rewrite assertion.

2. LOW: expired documents in live subscriber state, documented

I took the cheaper correct option and documented it. Publishing removed at the moment of expiry needs a timer or a frequent sweep, and expires_at has no index for that. The contract comment on T3TeamPackDocumentsStreamEvent (and on expiresAt) now says:

  • snapshots never contain expired documents;
  • removed arrives when the store deletes the row (the next write to that key, or the hourly pass), so a live subscriber can hold an expired document for up to an hour;
  • consumers that need exact expiry compare expiresAt themselves.

3. LOW: the pack-isolation test for removed now has a pack-b subscriber

It watches the same collection and prefix. After pack A's removals, pack B's own removal must be its first live event, and the assertion includes packId. Mutation check: dropping the pack filter in the hub fails it. The first version of this assertion did not catch that mutation, because the summary omitted packId.

4. LOW: deep nesting does not reproduce as a defect

A 20,000-deep array (40 KB, under the cap) already fails as a typed InvalidInput: the JSON encoder reports it as not serializable. Nesting up to about 5,000 levels is stored, read and serialized fine, and the failures start somewhere between 5,000 and 8,000. The RPC payload decode accepts the deep value too. I added no depth cap, only a regression test that pins the typed refusal through putFromView.

5. Note: views can name another pack's id

Pack web modules run in the host realm (no iframe). A pack view can therefore send any registered pack's packId on t3team.packStore.put. The server only checks that the id is in the registered set, which matches the spec's security model (views are bound to their registering pack id by the web host; the server re-checks membership). It is the same trust level as any host-realm code, and per-pack isolation for views would need a realm boundary.

Verification (local; fork CI skips tests)

Run serially: vp test run on t3team-packDocumentStore (6), …Retention (4), …ViewWrites (5), …Quota (2), and contracts t3team-packDocuments (4). All pass. Server typecheck with the Effect checker rules exits 0, and lint and the additive guard are clean.

🤖 Generated with Claude Code

Phil J and others added 5 commits October 7, 2026 21:49
Fails today: the quota is only enforced by the hourly retention pass and keep collections are
never evicted, so twenty 15 KB view writes all land in a 100 KB pack.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The quota was only enforced by the hourly retention pass, and keep collections are never
evicted, so a view writing a keep + viewWritable collection could grow a pack without bound
(and on an evictable one, push the pass into evicting the pack's own documents). View writes
now check the pack's live bytes inside the write transaction and refuse with QuotaExceeded when
the write would grow the pack past quotaBytes; rewriting a document no larger than before is
still allowed. Script writes keep the eviction-based quota, and can opt in with capAtQuota.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t a defect

Reproduced the review's concern: a 20_000-deep array under the byte cap fails with a typed
InvalidInput (the JSON encoder reports it as not serializable); nesting up to ~5_000 is stored
and served. No depth cap is needed; this pins the typed failure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:M labels Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

⚠️ The latest CI run did not produce a thread transfer result for c9a7109.

This comment will update automatically after the next completed run.

@johnnyelwailer
johnnyelwailer marked this pull request as ready for review October 8, 2026 05:56
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T05:59:39.251933Z c9a7109 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@johnnyelwailer
johnnyelwailer merged commit 403b2c8 into main Oct 8, 2026
26 of 27 checks passed
@johnnyelwailer
johnnyelwailer deleted the fix/pack-store-review-followups branch October 8, 2026 05:57

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c9a71090ca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

sql`SELECT COALESCE(SUM(byte_size), 0) AS "total",
COALESCE(SUM(CASE WHEN collection = ${collection} AND doc_key = ${key}
THEN byte_size END), 0) AS "current"
FROM t3team_pack_documents WHERE pack_id = ${packId} AND ${live(now)}`.pipe(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude retired collections from quota accounting

When a pack update removes or renames a collection, its persisted rows remain, and the retention pass deliberately counts only currently declared collections (t3team-packDocumentRetention.ts:49-54). This query instead sums every live row for the pack, so inaccessible data from a retired collection can make all writes to a current viewWritable collection fail with QuotaExceeded; because validation prevents accessing the retired collection and retention will not remove it, the pack cannot recover through the store API. Restrict the total to the current configuration's declared collections.

Useful? React with 👍 / 👎.

Comment on lines +81 to +84
const { total, current } = yield* query.usage(collection, key, now);
const growth = encoded.bytes - current;
if (growth > 0 && total + growth > quotaBytes)
return yield* refuse("put", "QuotaExceeded", `${total + growth} > ${quotaBytes} bytes`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve CAS misses before applying quota refusal

When a view submits a stale ifVersion—or ifVersion: 0 for an existing key—with a larger payload while the pack is near its quota, this check returns QuotaExceeded before the conditional update discovers that the CAS lost. The RPC contract and existing store behavior use null for that outcome, so contention is misreported as storage exhaustion and clients cannot follow their normal CAS retry path. Apply the quota check only after establishing that the requested version can be written, or include the version condition in the accounting/write operation.

Useful? React with 👍 / 👎.

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

Labels

size:M vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant