Skip to content

fix(objectql): fold internal: true into the aggregate guard (#7922) - #7984

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-7922-internal-flag-aggregation-guard
Aug 12, 2026
Merged

fix(objectql): fold internal: true into the aggregate guard (#7922)#7984
huangyiirene merged 1 commit into
mainfrom
claude/issue-7922-internal-flag-aggregation-guard

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Closes#7922.

⚠️ Grade first: this is a latent inconsistency, not a live disclosure

Nothing is disclosed by this today, and this is not a security fix. There is no /data/:object/aggregate route, and analytics requires a declared dataset — no reachable caller could reach the gap. Re-checked on this branch, the card's grade stands unchanged.

It is closed because the inconsistency is what bites the next adopter, not the current one. sys_api_key.key is a SHA-256 hash (aggregating it leaks little); sys_session.token (#7823) is a live bearer credential. The flag's own declaration — "the declared value is never returned on the generic data path" — reads as though it already covered both.

The gap

rejectCredentialAggregation decided what to refuse by asking collectCredentialFields, a collector keyed on the field TYPE (secret / password). ADR-0100's third credential channel is an auth-subsystem one-way hash living in an ordinary text column, which no type-keyed collector can ever reach — which is precisely why #7728 (PR #7920) minted the type-independent internal: true flag and taught find / findOne / the 201 create body / the by-id update body to omit it.

The aggregation guard was never taught the same thing. The read path understood "protected by flag"; the guard still only understood "protected by type". A flagged column that find omitted could be named as a groupBy dimension or a MIN/MAX measure and come back as the group key itself — the flag's promise stopping at the edge of aggregate().

The composition, measured (not inferred from the names)

The card's ⚠️ asked for this to be measured before writing the guard. Both collectors live in packages/objectql/src/secret-fields.ts:

keyed onmanagedBy treatmenton itest_api_key (better-auth, key: text + internal)
collectCredentialFields (:169)type === 'secret' || 'password'none — deliberately unconditional[]
collectInternalReadFields (:144)internal === true (strict)none — no exemption by design['key']

The union is clean, and for a stronger reason than "they happen to agree": neither side has a managedBy exemption, so the union cannot acquire one. The exemption that exists elsewhere (collectMaskedReadFields skips password on better-auth so login reads still see the stored value) is on a third collector that does not feed this guard at all. There is no interaction to get wrong.

Two details the measurement did surface, both handled:

  • The two sets can overlap (a secret column also flagged internal), so the union is deduped — otherwise the error message names the field twice. Pinned by a test.
  • The collectors are not collapsed into one. Their other consumers answer differently: the read path MASKS a credential type and OMITS a flagged field, and a flagged column must never acquire a mask (on a required column the mask is zero bits, and it would still put a value under a field whose declaration promises none). Composition happens at the call site; both docstrings now say so.

What changed

No new error code is minted — the guard throws the same plain Error it always has, and the Cannot aggregate credential field(s) prefix is kept deliberately so the pre-existing #3171 assertions in secret-fields.test.ts stay green rather than being edited to match a new message.

Tests — the CONTROL cases come first, and are the load-bearing ones

A guard that refuses too much breaks analytics silently, so the three negatives were written before the guard was touched:

  1. an unflagged column (prefix) on an object that has a flagged one still aggregates — asserting the real buckets ({osk_: 2, svc_: 1}), not merely "does not throw";
  2. an object with no flagged field is untouched — and its column is deliberately also namedkey, so a guard collecting field names globally rather than per-schema would fail here;
  3. COUNT(*) on the flagged object is not a false positive.

Then the positives: flagged field as string groupBy, as a structured {field} bucket, as a MIN/MAX measure, still refused on a managedBy: 'better-auth' object, and the message names each refused field once without slandering the legitimate dimension alongside it.

Per the card, this extendsinternal-fields.test.ts rather than forking a second copy: the #3171 type-keyed floor in secret-fields.test.ts and the api-key-hash-not-serialized dogfood test are reused as-is and both still pass.

Reverse verification

predictionactual
Guard at origin/main, new tests in placethe 5 reject cases red, the 3 CONTROL cases greenexact5 failed | 13 passed. The failure output printed the disclosure itself: [{key: "sha256:deadbeefcafe", n: 1}, …] — one bucket per distinct hash, keyed by the hash.
Mutation A — protectedFields = Object.keys(schema.fields) (refuse everything)CONTROL 1 and 2 red; CONTROL 3 stays green (COUNT(*) references no field, so its falsifier is a different mutation)exact3 failed, the third being "names every refused field once", which also catches over-breadth by seeing prefix in the message
Mutation B — hit = protectedFields (refuse regardless of reference)CONTROL 1 and 3 red; CONTROL 2 green (its object has no flagged field, so the guard returns early)exact2 failed

All three CONTROL cases are therefore tests that can actually fail, which was the card's requirement for them.

Gates — all watched go green

gateresult
pnpm --filter @objectstack/objectql test✅ 188 files, 3339 passed
pnpm typecheck✅ 126/126
pnpm build✅ 71/71
pnpm check:nul-bytes
pnpm check:error-code-casing✅ (no code minted; run anyway)
pnpm check:query-options-erasure242 at the ceiling, 67 non-test unswept, none new
pnpm check:type-check-debt✅ 36 entries re-measured, none above its recorded number
api-key-hash-not-serialized.dogfood.test.ts✅ (the #7920 read-path floor)
eslint on the three changed files

⚠️ Both ratchets were initially red or at risk and were fixed in the diff, not by moving a baseline — worth a reviewer's eye:

  • check:query-options-erasure went red first (242 → 257): my new aggregate() calls used as any. Fixed by typing them — a const SYSTEM: EngineReadOptions for the trailing options, and contract-typed query literals. Back to 242.
  • check:type-check-debt then went red (objectql TEST_DEBT 355 → 356): the structured-bucket case is genuinely off-contract, because EngineAggregateOptions.groupBy is declared z.array(z.string()) while the engine reads { field, dateGranularity } buckets too. Spelled as unknown as EngineAggregateOptions — the form the ratchet's own message prescribes for deliberately off-contract input, which names the contract being bypassed instead of erasing it. Back to 355.

Neither baseline was raised and --lower was not run. (check:type-check-debt was run against a built closure, so its verdict is about this diff rather than a missing-module cascade.)

Found and deliberately NOT fixed

Left as a draft for PM review.


Generated by Claude Code

`rejectCredentialAggregation` decided what to refuse by asking
`collectCredentialFields`, a collector keyed on the field TYPE
(`secret` / `password`). That left it blind to ADR-0100's third
credential channel — an auth-subsystem one-way hash living in an
ordinary `text` column — which is exactly the channel #7728 minted the
type-independent `internal: true` flag for.
So the read path understood "protected by flag" while the guard still
only understood "protected by type": a flagged column that `find`
omitted could be named as a `groupBy` dimension or a MIN/MAX measure
and come back as the group key itself.
The guard now takes the deduped union of the two collectors.
Composition happens at the call site — the collectors stay separate
because their other consumers answer differently (the read path MASKS a
credential type and OMITS a flagged field, and a flagged column must
never acquire a mask).
Nothing is disclosed by this today: there is no `/data/:object/aggregate`
route and analytics requires a declared dataset, so no reachable caller
could reach the gap. It is closed because the inconsistency is what
bites the next adopter — `sys_api_key.key` is a SHA-256 hash, but
`sys_session.token` (#7823) is a live bearer credential.
Tests extend the existing #7728 floor (`internal-fields.test.ts`) rather
than forking a second copy. The three CONTROL cases come first and are
the load-bearing ones: an unflagged column on an object that HAS a
flagged one still aggregates, an object with no flagged field is
untouched, and COUNT(*) is not a false positive. An over-broad guard
breaks analytics silently, so all three were verified falsifiable
against two deliberate mutations of the guard.
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 9:27am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx(via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx(via @objectstack/objectql)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx(via packages/objectql)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/objectql)
  • content/docs/kernel/services.mdx(via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx(via @objectstack/objectql)
  • content/docs/permissions/system-context.mdx(via packages/objectql)
  • content/docs/plugins/index.mdx(via @objectstack/objectql)
  • content/docs/plugins/packages.mdx(via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx(via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/objectql)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@huangyiirene