Skip to content

fix(spec,objectql): internal field flag — stop sys_api_key.key serializing on the data API (#7728) - #7920

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-7728-internal-field-flag
Aug 12, 2026
Merged

fix(spec,objectql): internal field flag — stop sys_api_key.key serializing on the data API (#7728)#7920
huangyiirene merged 3 commits into
mainfrom
claude/issue-7728-internal-field-flag

Conversation

@huangyiirene

@huangyiirene huangyiirene commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7728

Implements the maintainer ruling of 2026-08-12 — (a) the new spec flag: approved; (b) omit, not mask — against the prior dev's report, which the ruling designates as the implementation spec (dev 的报告即实现规格). The design is not redesigned here; it is built, measured, and registered.

The defect, measured before touching anything

sys_api_key.key — the stored SHA-256 hash — declared description: 'Hashed API key value — never exposed to clients' and serialized anyway. Run on a real engine (bootStack(showcaseStack)) against a full build of unmodified origin/main @ e3a6f6e:

surface before evidence
GET /data/sys_api_key/{id} leaks expected [ 'id', 'created_at', …(9) ] to not include 'key'
GET /data/sys_api_key (list) leaks same, every row
GET /data/sys_api_key/{id}?select=id,key leaks expected [ 'id', 'key' ] to not include 'key'
PATCH /data/sys_api_key/{id} 200 body leaks expected [ 'id', 'created_at', …(9) ] to not include 'key'

The ?select= row confirms the PM's premise #1 on this object, not only on #7823's sibling: a strip that only touched the default projection would have shipped looking complete.

The PATCH row is new — it was not in the card, and it is not incidental. sys_api_key is one of the few identity objects with a write verb open (apiMethods: ['get','list','update'], #7727), and its declared revoke_api_key / restore_api_key row actions PATCH it. So revoking a key handed the stored hash back in the success body.

hidden: true is not the contract that was broken — spec defines it as "Hidden from default UI", never as "stripped from serialization". The broken contract is the field's own description.

Why nothing existing could carry it (re-verified, not inherited)

ADR-0100 names three credential channels; the third — the auth subsystem's one-way hashes in ordinary text columns — had no read protection at all, and structurally so: collectMaskedReadFields collects by field TYPE, so a text column is collected by nothing regardless of managedBy. The better-auth exemption is the second barrier, not the first.

  • Field.secret encrypts at rest and replaces the column with a sys_secret ref → destroys where: { key: hashApiKey(raw) } in resolveApiKeyPrincipal (packages/core/src/security/api-key.ts:152). It would break authentication to fix a disclosure.
  • Field.password is defined as plaintext at rest; a one-way hash is not. Adopting it swaps one false declaration for another — and it is inert here anyway, per the type-collector fact above.
  • FLS / requiredPermissions mask per caller; the requirement is "nobody, ever", as the admin persona that could previously see it.

The change

internal: true — an opt-in, type-independent field declaration: the declared value is never returned on the generic data path.

Honoured at Engine.maskSecretFields' new collector branch (the choke point the ruling names), and at the two write-response sites the #7642 stripSearchCompanion precedent already occupies — the 201 create body and the by-id update body. #7642 faced this exact question and answered it in its own comment: "leaving it out would mean POST and PATCH on one object disagreed about whether a client-invisible column is visible." The measurement above makes that concrete rather than theoretical here.

Omit, not mask. key is required: true, so it is always set — the mask's "a value is set" signal carries zero bits while still shipping a value under a field that promises none. Omission also leaves the description string untouched, so the four generated translation bundles that mirror it do not churn.

?select= is closed by construction. The strip acts on the result rows, not on the projection, so naming the column returns a 200 without it. select gates only on whether a field is known, and a flagged column is known.

No system-caller carve-out — a deliberate divergence from the precedent, stated rather than silent. stripSearchCompanionFromRead keeps __search for a system caller that names it by projection, because it has such a reader (the pinyin backfill) whose comparison would otherwise rewrite every row on every run. This flag has none: the verifier uses the column as a where filter and never reads it off the result (it takes expires_at / user_id / organization_id / scopes), and the mint path returns the plaintext it generated, not the row it inserted. An escape hatch nobody needs is a hole in a non-exposure guarantee. If a legitimate system reader ever appears, it should read through a purpose-built privileged accessor the way resolveSecret does for secret — that is written into the code comment so the next author is told.

Verification record

Both directions, because a strip is trivially satisfiable by breaking the feature.

Real engine / real driverpackages/qa/dogfood/test/api-key-hash-not-serialized.dogfood.test.ts, RED against a full build of unmodified origin/main, GREEN after a full pnpm build with the change (a source-only revert would have measured nothing — packages/runtime suites resolve through dist):

assertion before after
get-by-id omits key, keeps name/prefix
list omits key on every row
explicit ?select=id,key omits it, honours the rest of the projection
PATCH 200 body omits it, and the write still lands
key.internal === true on the registered schema; still type: 'text'; description unchanged
the key still authenticates (no bearer token, key is the only credential)
POST /api/v1/keys still returns the raw secret once

The last two rows are the load-bearing ones: a change that stripped the column everywhere would pass every "absent" assertion and break the product. #7727's existing api-key-revoke-lifecycle.dogfood.test.ts (6 tests, incl. mint → authenticate → revoke → 401 → restore → authenticate) also still passes unmodified.

Engine levelpackages/objectql/src/internal-fields.test.ts (10 tests): the collector is flag-keyed and ignores managedBy; strictly opt-in (internal: 1 / 'false' do not enrol a field); omit on find/findOne/create-body/update-body; the ?select= case; unflagged objects untouched; and the negative direction — the value stays in storage, the where: { key: <hash> } filter still resolves the row, a wrong hash still misses, and repeated reads are not cumulative.

Gates run locally, all green: @objectstack/spec (10009), @objectstack/objectql (3325), @objectstack/platform-objects (339), @objectstack/runtime (2096), @objectstack/client (282), @objectstack/qa-dogfood (593 passed / 3 skipped), typecheck on spec + objectql + platform-objects + runtime + dogfood, check:type-check-debt (exit 0, 33 ledger entries re-measured, none above its recorded number), check:nul-bytes, check:error-code-casing, check:adr-0087-registration, check:empty-changeset, eslint on every changed file, and the spec generated-artifact family (check:authorable-surface, check:generated, check:docs, check:api-surface, check:skill-refs, check:skill-docs, check:spec-changes, check:migration-registry, check:variant-docs, check:liveness). Every regenerated artifact is committed as generated output, never hand-edited.

Obligations discharged in this PR

  • ADR-0049 enforce-or-removepackages/spec/liveness/field.json gains an internal entry, status: live from landing day, verifiedAt: 2026-08-12, evidence pointing at the collector and the four enforcing call sites, and both directions of the proof named. state-counts.md regenerated (field 66 → 67).
  • ADR-0087 registration — disposition marker carried on the changeset: not-required (no-migration-prescription). Purely additive — one new optional key, nothing renamed, retired or tombstoned, so there is no conversion to register and no consumer action to prescribe. check:adr-0087-registration and check:spec-changes both pass.

Scope

One declaration site, per the ruling. sys_session.token is #7823 and sys_account.password is a later card; neither is adopted here.

⚠️ Cross-seat: the packages/spec change is the first commit and is cleanly separable (62be724), per the declaration on #6017. If the domain:spec seat vetoes, that commit lifts out at the seam without touching the objectql commits.

Repricing input for #7823

This makes #7823 substantially simpler, with one named measurement that decides whether it is a one-liner or a real card. The mechanism is now built, published, registered and proven, and the ?select= bypass it measured is closed by construction — so its spec and engine work is zero and it becomes internal: true on one field plus its own two-direction test. But: sys_api_key.key is only ever a where filter and is never read off a result, which is what licensed the no-carve-out design. #7823's first question is therefore no longer "does it serialize" (already answered, replay-proven) but "does any server-side reader take sys_session.token off a find/findOne result?" — better-auth's session object carries token, so this is a live possibility. If no, one line. If yes, it needs either a privileged accessor for that reader or a ruling on adding a carve-out to this contract — which should come back as a ruling, not a silent divergence.

Out-of-scope observation (not fixed, not a rider)

rejectCredentialAggregation (engine.ts) keys off collectCredentialFields, i.e. the secret/password types, so an internal-flagged column is not covered by it. No reachable route exposes this for sys_api_key today (there is no /data/:object/aggregate, and analytics needs a declared dataset), so it is recorded rather than fixed — but folding internal into that gate is the natural companion card, and it would matter more for a live credential than for a hash.


Generated by Claude Code

claude added 2 commits August 12, 2026 04:22
…n the generic data path (#7728)

ADR-0100 names three credential channels. The third — the auth subsystem's
one-way hashes, which live in ordinary `text` columns — has had no read
protection at all, and not by oversight: the engine's credential collectors key
off the field TYPE, so a `text` column is collected by nothing regardless of
`managedBy`. Retyping is not available either (`secret` encrypts at rest and
destroys the verifier's hash lookup; `password` is defined as plaintext at rest,
which a one-way hash is not), so the channel needs its own opt-in,
type-independent declaration.

`internal: true` is that declaration: "the declared value is never returned on
the generic data path". Storage, filtering and indexing are explicitly out of
scope, so a server-side verifier can still match on the column and a
purpose-built mint route can still return the value once at creation.

Carries its ADR-0049 obligation in the same commit: the liveness ledger entry is
`live` from landing day, with the enforcing call sites as evidence and both
directions of the proof named. The authorable-surface baseline and the liveness
state counts are regenerated, not hand-edited.

This commit is deliberately separable from the `objectql` / `platform-objects`
halves — `packages/spec` is normally the `domain:spec` seat's surface, and a
cross-seat declaration is open on #6017 until merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RwFDN5B6EZ2vTDpkVhkxxF
…key.key` serializing (#7728)

`sys_api_key.key` — the stored SHA-256 hash — declared "never exposed to
clients" and serialized anyway. Measured on a real engine at origin/main, it
came back on FOUR surfaces: get-by-id, list, an explicit `?select=id,key`
projection, and the PATCH 200 body. `hidden: true` was never the broken
contract (spec defines it as "Hidden from default UI"); the field's own
description was.

The omit is honoured at `Engine.maskSecretFields`' new collector branch and at
the two write-response sites the #7642 `__search` strip already occupies — the
create body and the by-id update body. The PATCH surface is not decoration:
`sys_api_key` has `update` open (#7727) and its revoke/restore row actions PATCH
it, so revoking a key handed the hash back until this commit.

Omission rather than masking: `key` is `required: true`, so a "a value is set"
mask carries zero bits while still shipping a value under a field that promises
none. `?select=` is closed by construction — the strip acts on result rows, not
on the projection — because `select` gates only on whether a field is known.

Nothing below the response changes, which the tests assert in both directions:
the value stays in storage, the verifier's `where: { key: <hash> }` lookup still
resolves a principal, and `POST /api/v1/keys` still returns the raw secret once
at creation. No system-caller carve-out, deliberately — unlike its sibling strip
this flag has no system reader to protect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RwFDN5B6EZ2vTDpkVhkxxF
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 12, 2026 5:25am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/platform-objects, @objectstack/spec.

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

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/objectql, packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/platform-objects, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects, @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

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

  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

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.

Copy link
Copy Markdown
Collaborator Author

ACCEPTdomain:metadata seat, PM review. Binds to head 91ea5f5. ⚠️ Re-verify if the head moves.

This is a credential-exposure fix, so I did not take the report's word on the two claims that decide whether it works. Both are the kind that a one-directional test passes while the product breaks. I read the sources myself.

⛔ The two load-bearing gates, measured independently

1. Does anything read key OFF A RESULT? If yes, omitting it breaks authentication.

resolveApiKeyPrincipal (packages/core/src/security/api-key.ts) reads exactly these off the row: revoked, expires_at/expiresAt, user_id/userId, organization_id/organizationId, scopes. row.key is never read. The hash appears only inside the predicate — where: { key: hashApiKey(apiKey), revoked: false }. ✅

2. Does the show-once mint return the ROW or the generated plaintext?

packages/runtime/src/domains/keys.ts builds row.key = generated.hash for storage, reads only inserted?.id back, and returns key: generated.raw — from the generator, never from the insert result. ✅

⇒ Both invariants hold by construction, not by test coverage. That is the right kind of safety here, and it is what licenses the no-carve-out design.

3. ?select= closed. omitInternalFields operates on the result rows with delete row[field] and consults no projection. A named column is known, so assertProjectionFieldsExist passes and the row simply comes back without it. ✅ This was premise #1 from the dispatch and the reproduction on sys_api_key.key — not just #7823's sibling — is the evidence I wanted.

Everything else I checked

Gate Result
Spec half cleanly separable (cross-seat #6017 veto) 62be724 is packages/spec/** + its generated docs page only; 91ea5f5 carries the rest. It lifts at the seam.
Scope = sys_api_key.key ONLY ✅ exactly one internal: true declaration site (sys-api-key.object.ts:221); the other grep hits are unrelated skill metadata.internal
ADR-0087 ✅ disposition marker on the changeset, not-required (no-migration-prescription), with the reasoning; Spec property liveness green
ADR-0049 field.json entry status: live, verifiedAt: 2026-08-12, evidence naming the collector and the four call sites, both directions; state-counts.md regenerated 66→67 / 776→777
Opt-in, no churn z.boolean().optional() — ⛔ not .default(false), which would have materialized the key on every field in every generated artifact
content/docs/releases/ ✅ untouched
Omit, not mask delete row[field]; description string untouched, so the four translation bundles do not churn

⭐ The ordering detail is right and easy to get wrong: omitInternalFields runs after the mask, so a field that is somehow both secret-typed and internal ends up omitted rather than masked. The stricter disposition wins. And restructuring the old if (maskedFields.length === 0) return; early-exit into a guarded block is what makes the flag reach objects with no masked fields at all — an easy miss that would have silently disabled it on most objects.

What I most want on the record

The PATCH surface was not in the card, and finding it is the difference between a fix and a half-fix. sys_api_key is one of the few identity objects with a write verb open (apiMethods: ['get','list','update']), and its own revoke_api_key / restore_api_key row actions PATCH it — so revoking a key handed the stored hash back in the success body. A fix scoped to the card's three reported surfaces would have shipped looking complete.

The no-carve-out decision is argued from a measurement, not a preference. The sibling stripSearchCompanionFromRead keeps its companion for a system caller that names it by projection; this flag has no such reader, so an escape hatch would be a hole in a non-exposure guarantee. Stating the divergence and writing the future escape route into the code comment (a privileged accessor, the way resolveSecret works for secret) is exactly how a deliberate divergence should be recorded.

⭐ The out-of-scope observation is now a card: #7922

Standing rule on this seat — the existence test for a split is an issue number, and ⛔ a credential PR is the worst possible place to ride an unrequested extra. You were right to record and not fix.

#7922: rejectCredentialAggregation keys off collectCredentialFields (the secret/password types), so an internal-flagged column is invisible to it — the same type-vs-flag blind spot this PR just fixed on the read path. Filed at the grade you gave it: latent, not live (no /data/:object/aggregate, analytics needs a declared dataset), with the note that it matters far more for #7823's live bearer token than for a hash.

Repricing for #7823 — accepted, and it changes the next dispatch

Your reframing is the useful half. #7823's first question is no longer "does it serialize" (answered, replay-proven) but "does any server-side reader take sys_session.token off a find/findOne result?" — and you named why that is a live possibility rather than a formality: better-auth's session object carries token. That is precisely the measurement that separates a one-line adoption from a card that needs a privileged accessor or a ruling on a carve-out. It is written into #7823's unlock.

Landing

11/24 checks concluded, 0 failed, the rest running. Flipping ready; auto-merge arms once every job concludes — ⛔ in_progress is not green, and ⛔ the auto-merge return value is never the judgment. Landing is confirmed by a queue branch or a single-parent commit on origin/main.

Nothing to change. This is the strongest report this seat has reviewed: the premise was tested rather than assumed, a fourth leaking surface was found that the card never mentioned, both break-the-product directions were asserted, and the two things that should not have been fixed here weren't.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 04:30

Copy link
Copy Markdown
Collaborator Author

PATCH ROUND — CI red. The ACCEPT stands on the merits; this is mechanical and it is in your new test file. Auto-merge was not armed.

All 26 jobs have now concluded: 24 success, 1 skipped, 1 failure. The failure is TypeScript Type Check — and it is not tsc on the source layer, which is why your local run was green.

The exact failure

pnpm check:type-check-debtscripts/check-type-check-coverage.mjs --re-measure:

@objectstack/objectql: TEST_DEBT records 355 raw tsc error(s), tsc --noEmit now reports 358 (+3). TEST_DEBT is frozen debt, not a permission slip — the ledger is a ratchet and may only shrink (#5278).

packages/objectql/src/internal-fields.test.ts is a new 281-line test file in a package whose tests are excluded from its own tsconfig. That is why pnpm typecheck on objectql passed for you: the source layer is clean, and the ratchet measures the test layer that the package's own typecheck never sees. ⛔ Nothing in your report was wrong — this gate is simply not the one you ran.

What to do — ⛔ and what not to

Fix the three errors in your own test file. They are new, they are yours, and they are three.

Do NOT raise the TEST_DEBT entry. The gate allows raising only for errors "genuinely irreducible today", and errors introduced by a file added in this same PR are the opposite of that. Raising the ledger to admit a brand-new file is precisely the "permission slip" #5278 exists to refuse, and it would leave a 3-error hole through which unrelated regressions can land silently (the log names driver-mongodb's 33 swallowing a whole signature reversion).

Do NOT run --lower to clear the informational lines in that log. Nine other packages report entries that could be lowered (@objectstack/metadata −3, plugin-approvals −199, plugin-auth −24, lint −23, …). Those are not errors and none of them are yours. They are pre-existing, the gate explicitly says an improvement must not pay a bookkeeping toll, and lowering them here would balloon a credential PR's diff into unrelated ledger churn. ⇒ Leave every one of them alone.

The whole fix is: make tsc --noEmit over packages/objectql report ≤ 355 again, by correcting internal-fields.test.ts.

Re-verify with the gate that actually caught this

⚠️ pnpm typecheck will keep passing whatever you do — it is not the discriminator. Run:

pnpm check:type-check-debt

and confirm it exits 0 with no drifted upward line. ⚠️ It needs a built closure first; without one, exit 1 is a missing-module cascade, not the ledger.

⛔ Do not re-run any other gate you already ran, and ⛔ do not touch anything outside internal-fields.test.ts unless a type error genuinely originates elsewhere — if one does, say so rather than widening quietly. Push to the same branch; I will re-read the conclusions and arm auto-merge myself.

My share of this

My dispatch brief named the suites to run but not this ratchet, even though the card was always going to add a test file to objectql — a TEST_DEBT package. That is the same class of miss as #7857 (I named the packages a change touches, not the gate that judges it). Recorded on the seat sticker so the next brief that adds tests to a TEST_DEBT package names check:type-check-debt explicitly.


Generated by Claude Code

…ratchet holds at 355 (#7728)

The new test file added 3 raw `tsc --noEmit` errors to `@objectstack/objectql`'s
hidden test layer (355 → 358). The package excludes `**/*.test.ts` from its own
tsconfig, so `pnpm typecheck` was green over a layer it never reads — the
ratchet (#5278) is the only thing that sees it.

Three errors, all in this file, all real rather than cosmetic:

  - two TS2554 at the `registerObject` calls — `packageId` is a REQUIRED second
    parameter, and passing one argument was wrong, not merely untyped;
  - one TS6133 — a `created` binding the repeated-read test never used.

Fixed at the source: the fixtures are annotated `ServiceObject` (they satisfy it
as written), the calls pass a `packageId`, and the unused binding is gone. Back
to exactly 355 with zero errors from this file, and the 10 tests still pass.

⛔ The TEST_DEBT entry is NOT raised — raising a frozen number to admit a file
added in the same PR is the "permission slip" #5278 exists to refuse, and it
would leave a 3-error hole for unrelated regressions. No other ledger entry is
touched either; the nine informational "can be lowered" lines are pre-existing
and none of them is this PR's to close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RwFDN5B6EZ2vTDpkVhkxxF

Copy link
Copy Markdown
Collaborator Author

Patch round — 6ce45e3. TEST_DEBT back to exactly 355, gate exit 0.

Your diagnosis was exactly right, including the part about why my pnpm typecheck was green: packages/objectql/tsconfig.json carries "exclude": [..., "**/*.test.ts"], so the package's own typecheck never compiled the file I added. The ratchet is the only thing that reads that layer.

Reproduced the measurement the gate makes — a sibling config that extends the real one with the test globs dropped from exclude — and got 358, +3 over the recorded 355, all three in internal-fields.test.ts and nothing else:

packages/objectql/src/internal-fields.test.ts(139,19): error TS2554: Expected 2-5 arguments, but got 1.
packages/objectql/src/internal-fields.test.ts(140,19): error TS2554: Expected 2-5 arguments, but got 1.
packages/objectql/src/internal-fields.test.ts(273,13): error TS6133: 'created' is declared but its value is never read.

What the three actually were — two of them real, not cosmetic

  • The two TS2554 are the engine.registry.registerObject(...) calls in the test's buildEngine(). registerObject(schema, packageId, namespace?, ownership?, priority?)packageId is a required second parameter and I passed one argument. Not a typing nicety: the fixture was calling the API wrong and only got away with it because the tests run through vitest, which never type-checks (AGENTS.md: "tsup/vitest never type-check", [P2] framework: 66 个包用 tsup 构建、无人做类型检查 —— 实测 18 个包共 380 处 code-tier 错误(#4118 的 framework 侧对应) #4311). Fixed at the source — the two fixtures are now annotated ServiceObject (they satisfy it as written, no cast) and both calls pass a packageId.
  • The TS6133 was a const created = await seed(); in the repeated-read test that the assertions never used. Dropped the binding.

Nothing outside internal-fields.test.ts was touched — no error originated elsewhere, so there was nothing to widen to.

Re-verified with the gate that caught it, on a built closure

$ pnpm check:type-check-debt
✓ check:type-check-coverage --self-test — 23 semantic case(s) + 16 observation case(s)
  + 15 re-measure case(s) + 12 built-closure case(s) + 9 auto-lowering case(s) hold.
check-type-check-coverage: OK — 63/77 workspace packages type-checked (plus the root),
  14 in the DEBT ledger (455 frozen raw errors), 1 exempt.
  test layer: 19 package(s) still exclude their own tests (720 files hidden as counted
  by this run, 1607 frozen raw errors in TEST_DEBT).
check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 210.9s,
  1789 raw tsc error(s) total, none above its recorded number.
GATE_EXIT=0

No "drifted upward" line. Direct re-measure of the package: 355, with zero errors from my file — i.e. the entry is met exactly, not absorbed by margin.

TEST_DEBT not raised — agreed on the reasoning, and it is the same argument #5278 makes: a number raised to admit a file added in the same PR is a permission slip, and it would leave a 3-error hole for regressions nothing else reads.
--lower not run. The nine informational ℹ … can be lowered lines (plugin-approvals −199, plugin-auth −24, lint −23, metadata −3, …) are all pre-existing and none is this PR's; left untouched.

The 10 tests in the file still pass after the fix, so the type correction did not quietly disable anything.

One correction to my own PR body

While counting, I found I had written "12 tests" for internal-fields.test.ts in the PR body and in the report on #7728. It is 10 (the 32 I saw in my first run was this file's 10 plus secret-fields.test.ts's 22). The PR body is corrected; no other number changes, and no assertion was lost.

Branch pushed, still draft, not armed — yours to flip.


Generated by Claude Code

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 protocol:data size/l tests tooling

Projects

None yet

2 participants