Skip to content

fix(plugin-audit,platform-objects,plugin-auth,plugin-sharing,plugin-approvals,objectql)!: the audit ledger honours internal, and the credential-class census is declared internal - #21301

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-21197-internal-credentials-ledger
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-21197-internal-credentials-ledger

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Part of #21197. This PR lands the ruled mechanism (C2) for the whole credential-class census, with its pins. It leaves one ruled execution parameter open: the upgrade-time invalidation of outstanding one-time credentials (see "What this PR does not do").
Clause-②: no (narrowing)

Executes ruling record 5942811916 (letter C2, Q2 A; the maintainer's 「同意」), re-claim 5942885323. Classes and positions only.

What changed

1. The ledger mirror honours internal (plugin-audit, audit-writers.ts).

  • ledgerView now omits every field the object declares internal: true, through objectql's existing flag-keyed collector collectInternalReadFields. It runs beside the type-keyed mask (collectMaskedReadFields) and after it, the engine's own order. No third predicate.
  • It applies on every side the mirror records: create new_value, both halves of an update diff, delete old_value, the activity row's metadata, and the label source.
  • An update that changes only an internal field still writes its row, with neither value.
  • The title-read skip uses the same composition.
  • @objectstack/objectql re-exports collectInternalReadFields from its main and /core entries. This is a pure re-export with no logic, and the lean-entry closure gate is green.

2. The credential-class census is declared internal. No field is retyped to secret.

  • platform-objects:
    • the signing-key object's private key;
    • both credential columns of the one-time verification object;
    • the second factor's secret and backup codes;
    • the SSO provider's OIDC and SAML blobs;
    • the OAuth access- and refresh-token columns;
    • the OAuth client secret digest;
    • the SCIM credential digest.
  • plugin-sharing: the share link's token and password hash.
  • plugin-approvals: the action-token digest.
  • Already internal, and now honoured by the ledger: the API-key digest and the email headers column.
  • The one-time verification object's record title becomes a fixed label, and its highlights drop the identifier column. The read path evaluates formulas before it omits internal fields, so a title built from an internal column would re-serve it.

3. Every consumer that reads a declared column back gets it through the engine's privileged accessor (resolveInternalField), never through the generic path.

  • plugin-auth internal-field-readback.ts: readback rows for the signing key (JWT signing), the verification value (reset, OTP, magic link, OIDC code redemption), both second-factor columns, both SSO blobs, and the OAuth client secret.
  • plugin-auth objectql-adapter.ts:
  • plugin-auth sso-client-secret.ts: the legacy cleartext-secret migration reads the OIDC blob through recoverInternalFieldsForSystemRead.
  • plugin-sharing:
    • redemption and the redemption probe recover the password hash through a fail-closed helper (readShareLinkInternalColumn); the helper refuses rather than answer "unset" wherever the registered declaration says the strip ran;
    • the creator's link list keeps returning each token;
    • the resolved link carries the presented token and no hash.

Lookup-only columns need no readback: the OAuth token columns, the SCIM digest and the approval-token digest are only ever filtered by.

Fix round (head 1704f949d9, after contract review FAIL 5944831998)

Section added by the seat (domain:engine#1), from the dev's fix-round report and the re-review PASS 5946347621. Claim amendment: 5946149793.

  • A missed consumer, fixed. packages/runtime/src/domains/share-links.ts, the share-link resolve probe, read the stored hash off a generic read. Once the column became internal, a password-protected link answered the unknown-link shape. It now recovers the hash through objectql's new helper, fail-closed: a recovery it cannot make throws and answers an error, never an open link. It is pinned on a real engine: unknown token 404; protected without a password 401 NEEDS_PASSWORD; wrong password 401 WRONG_PASSWORD; right password 200. Reverting the recovery turns the pin red.
  • One recovery helper. objectql secret-fields.ts gains readInternalColumn, exported from both entries. It decides stripped versus unset by the same collector the engine's strip uses, recovers through resolveInternalField, and fails closed. plugin-sharing's helper now delegates to it, so there is no second copy of the rule.
  • Two CI reds, fixed without weakening an assertion.
    • The dogfood two-factor storage test reads the stored column at driver level.
    • The plugin-security private-credential row-scope controls keep their row-scope assertions verbatim and prove the value through the privileged read. Filtering on the now-internal lookup column still finds the row.
  • Verification at the fix round:
    • The Test Core affected set, as CI computes it: 49 suites and 43,581 tests. The one failure is a cli integration control that fails identically on clean main in the dev's container and passes in CI.
    • The whole dogfood suite: 1,347 tests, exit 0.
    • Gates: 73 derived, 73 run, 0 not measured.
    • Reverse checks R1 to R3 red as predicted.
    • CI on 1704f949d9: every required context green.
  • Repo-wide check: every generic read of a now-internal column across packages/** and apps/** source was classified (151 hits): fixed, lookup-only, reads non-internal columns only, or not a reader. The re-review spot-checked it at the head.
  • Still open (unchanged): the ruled upgrade-time invalidation of outstanding one-time credentials. Its wiring was refused by the authoring session's permission policy and not routed around. It waits on the maintainer, so this PR stays Part of #21197.

What this PR does not do

  • The upgrade-time invalidation of outstanding one-time credentials is not in this PR. A once-per-deployment sweep was designed: it keeps a sys_migration receipt and spares replay tombstones. Wiring it into the auth plugin's boot was refused in the authoring session by its permission policy, and it was not routed around. The seat holds the design. The changeset does not claim this invalidation.
  • No ledger rewrite or scrub (limb ③ stays undone, by ruling). No retype to secret. The CLI dev-mode secret fallback is out of scope, as ruled.

Pins

Each pin is described by what it asserts.

  • plugin-audit audit-internal-field-omission.test.ts, over the real platform-object definitions (aliased to source): for every census platform object, the declared internal set equals the census. Ledger create, update and delete rows, and the activity rows, carry none of those fields on either snapshot side. A non-credential control column is recorded on every side it changed. An internal-only update still writes its row.
  • plugin-auth internal-credential-readback.test.ts, on a real engine with SQLite and a real auth manager:
    • the signing-key object signs on the first request, a later request and a fresh-manager request, using a key row minted while the column was still undeclared (an engine over the same database file), and every token verifies against the served JWKS;
    • the one-time credential class: password reset end to end, and single use;
    • the second factor: TOTP enrolment and a single-use backup-code challenge;
    • the OAuth client secret: a confidential client authenticates to introspection, and a wrong secret is refused;
    • each case also asserts the generic read omits the column.
  • plugin-sharing share-link-internal-columns.test.ts, on a real engine:
    • a password-protected link refuses without its password and with a wrong one, and opens with the right one, in the service and through the route;
    • the probe answers password-required;
    • the list and the resolve route keep the token;
    • the generic read omits both columns.
  • plugin-approvals approval-token-internal-hash.integration.test.ts: peek, redeem and replay resolve on a real engine with the digest stripped from reads.
  • dogfood audit-log-internal-fields.dogfood.test.ts, on a real showcase boot with the audit plugin: the admin's ledger by-id and query doors serve none of an API key's or a password-protected share link's internal columns, and the control column is served.

Reverse verification

Each ablation was run from the committed state through scripts/ablation-replace.mjs, and each restore was proven: the blob equals HEAD and git diff HEAD is empty.

  • Mirror limb removed: the plugin-audit pin goes red (20 of 30). The 10 declaration-set cases stay green by design.
  • The same, through dist/: plugin-audit rebuilt, and ablation-dist-preflight confirms the limb is absent. The dogfood pin goes red, 3 of 3. Restored, rebuilt, preflight confirms it present again, and the pin is green.
  • Signing-key readback row removed: signing fails on the first post-upgrade request.
  • consumeOne re-attach removed: reset answers 400.
    • The first attempt at this ablation was refused by the tool before running (the replacement text already occurred), so it measured nothing. The delete-mode rerun is the measurement.
  • Second-factor rows removed: the real-engine second-factor suite fails enrolment.
  • Backup-code row alone removed: the backup-code challenge fails.
  • OAuth client-secret row removed: the right secret is refused, because the client reads as public.
  • Share-link hash recovery replaced with a direct row read: a protected link opens without its password, through the service and the route.
  • findOne returned to the old order (inject before re-attach): three cases of the SSO at-rest suite go red.
  • Legacy-migration recovery removed: the migration finds nothing to migrate.

Verification, first round (head 03a77fb1b6, after merging main; the fix round's readings are under "Fix round" above)

  • Typecheck exits 0 for plugin-audit, plugin-auth, plugin-approvals, plugin-sharing, platform-objects, objectql and dogfood.
  • Package suites, all green:
    • plugin-audit: 36 files, 565 tests;
    • plugin-auth: 117 files, 2489 tests;
    • plugin-sharing: 38 files, 918 tests;
    • plugin-approvals: 54 files, 825 tests;
    • platform-objects: 59 files, 949 tests;
    • objectql: 360 files, 7082 tests.
  • Dogfood: the new pin plus seven existing ledger, API-key and share-link files: 8 files, 40 tests.
  • Derived gates: dispatch-gates --commands derives 73 families. All 73 exit 0 on this head, and --ran reconciles 73 derived, 73 run, 0 not measured, with every exit code recorded. check:tenant-chokepoint was also run, exit 0.
  • Declared to CI, not run locally: the full dogfood suite and the repo-wide pnpm lint. ESLint over the new test files exits 0.

Notes for review

  • Changeset level. minor for the five packages whose served surface narrows (declared breaking, Clause-②: no (narrowing), ADR-0087 disposition not-required (no-migration-prescription), accepted by check:adr-0087-registration). minor for @objectstack/objectql, which adds exports: collectInternalReadFields re-exported, and the new readInternalColumn helper with its parameter type InternalColumnSource. patch for @objectstack/runtime, for the share-link probe fix. (Corrected by the seat for the fix round; the first round shipped objectql as patch.) The fixed group moves every package together in any case.
  • The changeset tells operators to rotate the JWT signing keys, and to revoke and re-mint share links that must stay private. Ledger rows written before the upgrade are not rewritten.
  • #21259 (serial neighbour, the MySQL key-table timestamp write) landed on main before this branch's merge of main. No overlap.

Acceptance notes

These are observations, not filed:

  • No authoring check refuses a formula or title that reads an internal field, and the read path evaluates formulas before the strip. The one in-tree producer, the one-time verification object's title, is fixed here. Carrier: none.
  • The share-link create route answers with the inserted row, hash included, to its creator. This is the plugin's own write response, not a generic exit. Carrier: none.
  • The console types a password_protected projection for listed share links, and the list route has never served it. Carrier: none.
  • The OAuth refresh-token row's cached rotation-replay response is credential-class, but it is never written here: the reuse interval is unset. If that is enabled, the column needs the flag and a readback row. Carrier: none.
  • Filtering on an internal column stays allowed, as the flag's declaration states. Carrier: none.

Generated by Claude Code

claude added 9 commits October 2, 2026 00:11
…pprovals,objectql): honour the internal declaration at the ledger mirror and declare the credential-class census internal

The compliance ledger's CRUD mirror omits fields declared internal from both
snapshot sides, through objectql's existing flag-keyed collector, beside the
type-keyed mask it already applied. The credential-class fields of the
identity, share-link and approval-token objects are declared internal, and
every consumer that reads one back off a row gets it through the engine's
privileged accessor: plugin-auth's readback table (signing key, one-time
verification value, second factor, SSO protocol blobs, OAuth client secret),
the adapter's consume verb, the legacy SSO secret migration, and the
share-link service's redemption, probe and list reads.

WIP: pins, changesets and gates follow.

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
…sumer that reads a declared column back

- plugin-audit: create, update and delete ledger rows about each census
  platform object record a non-credential control column and none of the
  object's internal columns, on both snapshot sides and in the activity row;
  an update of only an internal column still writes its row.
- plugin-auth: JWT signing on the first, a later and a fresh-manager request
  with a key row minted before the declaration; password reset end to end with
  single-use enforcement.
- plugin-sharing: password-protected redemption refuses without and with a
  wrong password and opens with the right one, through the service and the
  route; the creator's list and the resolve route keep the token.
- plugin-approvals: the actionable-link lookup resolves on a real engine with
  the digest stripped from reads.
- dogfood: the admin's ledger by-id and query doors serve none of an API
  key's or a share link's internal columns.
- platform-objects: the one-time credential object's title reads no
  internal column; the English bundle follows its description.

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
…real engine (enrolment TOTP, single-use backup code)

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
…tial client authenticates to introspection on a real engine)

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
…ensus is declared internal, with the rotation notice

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
…egistered declaration, per row, failing closed only where the strip ran

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
- plugin-auth: the readback pin is an ordinary unit-tier file, not a
  nightly-tier one.
- plugin-audit: alias the identity subpath to source, so the census
  declarations judged are the checkout's; the fake driver applies the
  caller's bound after the filter.
- plugin-approvals: the generic read's options are typed, not erased.

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 7 package(s): @objectstack/objectql, @objectstack/platform-objects, @objectstack/plugin-approvals, @objectstack/plugin-audit, @objectstack/plugin-auth, @objectstack/plugin-sharing, @objectstack/runtime, touching 39 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/objectql/src/core.ts, packages/objectql/src/index.ts, packages/plugins/plugin-audit/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.

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

  • content/docs/concepts/architecture.mdx (via getSchema (symbol, a method of interface InternalColumnSource), getSchema (literal, a string literal in readShareLinkInternalColumn))
  • content/docs/data-modeling/formulas.mdx (via titleFormat (symbol, a field of const object SysVerification))
  • content/docs/data-modeling/objects.mdx (via highlightFields (symbol, a field of const object SysVerification), titleFormat (symbol, a field of const object SysVerification), expires_at (literal, a string literal in highlightFields))
  • content/docs/data-modeling/schema-design.mdx (via highlightFields (symbol, a field of const object SysVerification), titleFormat (symbol, a field of const object SysVerification))
  • content/docs/deployment/cli.mdx (via highlightFields (symbol, a field of const object SysVerification))
  • content/docs/deployment/self-hosting.mdx (via expires_at (literal, a string literal in highlightFields))
  • content/docs/permissions/authentication.mdx (via createObjectQLAdapterFactory (symbol, a top-level function), sys_verification (symbol, a field of const object enObjects), expires_at (literal, a string literal in highlightFields))
  • content/docs/permissions/authorization.mdx (via sys_sso_provider (symbol, a field of const object READBACK_FIELDS), sys_verification (symbol, a field of const object enObjects))
  • content/docs/permissions/system-context.mdx (via listLinks (symbol, a method of class ShareLinkService))
  • content/docs/permissions/tenant-audit-census.mdx (via sys_sso_provider (symbol, a field of const object READBACK_FIELDS))
  • content/docs/protocol/kernel/realtime-protocol.mdx (via expires_at (literal, a string literal in highlightFields))
  • content/docs/protocol/objectui/actions.mdx (via client_secret (literal, a string literal in READBACK_FIELDS))
  • content/docs/ui/forms.mdx (via highlightFields (symbol, a field of const object SysVerification))
  • content/docs/ui/setup-app.mdx (via sys_verification (symbol, a field of const object enObjects), private_key (literal, a string literal in READBACK_FIELDS))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx (via highlightFields (symbol, a field of const object SysVerification))
  • content/docs/releases/v15.mdx (via highlightFields (symbol, a field of const object SysVerification), expires_at (literal, a string literal in highlightFields))
  • content/docs/releases/v17/17-0.mdx (via sys_sso_provider (symbol, a field of const object READBACK_FIELDS), titleFormat (symbol, a field of const object SysVerification))
  • content/docs/releases/v17/17-4.mdx (via highlightFields (symbol, a field of const object SysVerification))
  • content/docs/releases/v17/17-5.mdx (via sys_sso_provider (symbol, a field of const object READBACK_FIELDS), sys_verification (symbol, a field of const object enObjects))

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.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/objectql/src/core.ts, packages/objectql/src/index.ts, packages/plugins/plugin-audit/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 36 pages)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 52 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 222ecc27f9050857d4620e7010d8b85f9d35e378 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 09db1fac87eb082beb83ecd6c565914f0f8062ca — the merge of head 1704f949d93f62417872e3404349787b41b24390 into base 222ecc27f9050857d4620e7010d8b85f9d35e378, 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 09db1fac87eb082beb83ecd6c565914f0f8062ca && git checkout 09db1fac87eb082beb83ecd6c565914f0f8062ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 222ecc27f9050857d4620e7010d8b85f9d35e378 1704f949d93f62417872e3404349787b41b24390 && git checkout -B drift-repro 222ecc27f9050857d4620e7010d8b85f9d35e378 && git merge --no-ff 1704f949d93f62417872e3404349787b41b24390

node scripts/docs-audit/affected-docs.mjs --json 222ecc27f9050857d4620e7010d8b85f9d35e378

⚠️ 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 222ecc27f9050857d4620e7010d8b85f9d35e378 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ns through the path that proves them, not the stripped generic read

- dogfood two-factor backup-code reveal: the "storage holds no plaintext"
  leg reads the stored column at driver level. The engine's read path omits an
  internal column for every caller, so its guard saw "nothing stored" for a
  column that holds a value. Both assertions are unchanged.
- plugin-security private credential row scope: the two controls prove row
  reach by id, assert the generic read serves no credential column, and prove
  the value through the engine's privileged accessor, the path the auth
  adapter's re-attach uses. The by-identifier lookup still finds exactly the
  member's row. The row-scope assertions are unchanged.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 03a77fb1b670ada66b19e1d37c637274732b2c65
Local-runs: none

Inputs: card #21197 (body and all eleven comments, among them the ruling 5942811916, the re-claim 5942885323, the amendments 5943017963 and 5943028352, the os-dev-report 5944632767), the #21118 notes 5942902099 and 5943032006, PR #21301 (body, 28-file list, diff — byte-equal to the local git diff against merge base c7396f1a), tree reads at the head through git show / git grep, and the head's check-runs read last at 2026-10-02T02:57Z. Classes and positions only; no value is quoted.

① Derived judgments

Ledger mirror (plugin-audit/src/audit-writers.ts) — right. ledgerView gains a third limb after the type-keyed mask: every key collectInternalReadFields(getObjectDef(objectName)) names is deleted. Every side the writer records passes through ledgerView: create new_value (L1514), both halves of the update diff (L1522–1523), delete old_value (L1525), the activity label source (L1585–1586), the milestone and tracked-change views (L1626–1627), and the activity metadata old/new (L1691), which reuses the same diff values; the bulk title read skips an internal title column (L1177). getObjectDef is engine.getSchema, the registry the engine's own strip keys on, so a plugin-registered object (sys_share_link) is covered — the dogfood pin proves it on a real boot. The "nothing meaningfully changed" skip is judged on the raw diff before the limb, so an internal-only update still writes its row (pinned). Mask-then-omit matches Engine.maskSecretFields.

objectql re-export — right, pure and additive. Two names added to core.ts and index.ts, re-exporting the existing secret-fields.ts function beside its siblings; no logic, and the /core entry already re-exports from that module, so the lean-entry closure is unchanged.

Census declarations — right, none retyped to secret. Eleven columns across nine objects gain internal: true (sys_api_key.key and sys_email.headers_json already carried it); the plugin-audit pin holds each object's declared set equal to the census. No census object declares a data-door method beyond get/list (sys_jwks: none), so no generic write door moves.

Readback — right where the diff reaches, wrong at one consumer it does not reach.

  • Right: the readback rows in internal-field-readback.ts for sys_jwks.private_key (absence proves the strip; required), sys_verification.value, both sys_two_factor columns, both SSO blobs and sys_oauth_application.client_secret; in objectql-adapter.ts the re-attach now runs before injectClientSecretOnRead on findOne (L909, L918) and findMany (L963, L966), the injection parsing the now-internal OIDC blob; consumeOne re-attaches before its delete (L1107); the legacy migration recovers oidc_config through recoverInternalFieldsForSystemRead (sso-client-secret.ts L280ff), and the two auth-manager recover sites are sys_account only. Engine-level insert and update results keep the stored row whole (engine L13578 and L15403, the A-prime ruling), so a create or update result handed to better-auth or to a plugin's own route still carries the column; createLink returns its local row. jwt-key-algorithm.ts reads keys through the adapter's findMany, so the jwks row covers it. sys_sso_provider is read-only on the data door, so no Setup edit form can blank a blob.
  • Wrong — a missed consumer: packages/runtime/src/domains/share-links.ts, the resolve probe (L170–L205). It reads password_hash straight off a generic engine.find('sys_share_link', …) to choose between the unknown-token answer and 401 NEEDS_PASSWORD / WRONG_PASSWORD. With the column internal that read is always absent, so on this surface every live password-protected link whose redemption failed now answers the unknown-token shape: the password prompt never appears, and a client that asks first cannot open the link. The gate is NOT opened — the service's fail-closed readShareLinkInternalColumn holds, and a caller already sending the right password still resolves — but the flow fails. The site's own comment names it the dispatcher twin of the plugin-sharing probe this PR fixed and, for cloud's per-environment kernels, the designed primary surface (registerShareLinkRoutes: false). Its pin, domains/share-links-enforcement-context.test.ts (around L829), drives a vi.fn service and engine that hand rows back whole, so it stays green through this — the fake-engine class the dev's own A8 note identified. packages/runtime is on no cross-lane declaration.
  • Not re-verifiable here: node_modules is absent from this checkout, so the dev's trace of the installed better-auth dist (no route reads verification.identifier or the OAuth token columns back off a row) rests on the dev's word plus the real-engine pins. The in-repo verifiers are confirmed lookup-only: SCIM reads active, expires_at and connection_id only (scim-connection-service.ts L199–230); approvals read action, approver_id and request_id only (approval-service.ts L4497ff); revokeLink projects without token.

Lookup-only columns — right, with the caveat above.

Titles, formulas, highlights — right. sys_verification's title is a fixed literal and its highlights drop identifier; no other census object carries a formula, titleFormat, nameField, displayNameField or highlightFields over a now-internal column (all eleven objects read at the head). For the pointer-less objects (sys_share_link, both OAuth token objects, sys_jwks) the designate-only pass lands on id, the first title-eligible field, not on the token column, and the engine synthesizes no title from nameField ahead of the strip.

Pins and A1–A10 — right as claimed, on real engines. plugin-audit: real ObjectQL hooks over an in-memory driver, judged at rest (the subject is the write, not the strip); plugin-auth, plugin-sharing and plugin-approvals: real SQLite engines, plugin-auth with a real AuthManager and a key row minted under the pre-change declaration; dogfood: a real showcase boot through built plugin-audit. The A1 counts (20 red of 30, the 10 declaration cases green) and A2 (3 of 3) match the suites' shapes; A8's fake-engine discovery was answered with a real-engine second-factor pin. The verification scope stopped at six package suites, eight dogfood files and the derived gate families; the two reds in ③ sit in the remainder the dev declared to CI.

② Semver level

  • Levels: minor for plugin-audit, platform-objects, plugin-auth, plugin-sharing and plugin-approvals under the ! marker, with Clause-②: no (narrowing) at line start — consistent with the repo's breaking-narrowing precedents and check:no-major; the fixed group moves them together. objectql at patch for an added export understates it: in-tree precedent (20312, 20553) writes a new export as a widening at minor. Immaterial to the published version under the fixed group; wrong as a declaration.
  • ADR-0087: not-required (no-migration-prescription) with its reason — the right arm (no authorable key, export or config field is removed or renamed; what narrows is what the generic read path and the ledger return), the same arm as 20166, 20280 and 20546; accepted by check:adr-0087-registration per the dev and by Check Changeset on this head.
  • Prose: carries the signing-key rotation notice and the share-link revoke-and-re-mint notice; does NOT claim the upgrade-time invalidation — it states the lifetime bound of a one-time credential's copy, which is accurate. It is silent that the ruled invalidation is not delivered; that is a residual for ③, not an inaccuracy. The "what changed" list matches the diff.

③ Boundary flags

  1. Required gates on this head are red (read 2026-10-02T02:57Z). Dogfood Regression Gate: failure — shard 2/3, packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts (L213–219 reads sys_two_factor.backup_codes through ql.find and asserts a stored value: a generic-path read of a now-internal column; the product path is pinned green by the plugin-auth real-engine second-factor case). Test Core (5/6): failure — packages/plugins/plugin-security/src/private-credential-row-scope.test.ts (L263–279 reads sys_verification and sys_jwks credential columns through engine.findOne / find on a real engine and expects the value: the same class). Lint & Repo Gates, TypeScript Type Check and the Test Core roll-up were still in progress — not yet verdicts. Build Core, Temporal Conformance (live PG + MySQL) and Governed Surface Queue Guard: success. The PR is draft, mergeable_state: blocked.
  2. The ruled upgrade-time invalidation is not delivered. The refusal came from the dev session's permission layer; it was not routed around, and the unwired module is out of the tree; the PR says Part of, not Fixes, and the part-of check on this head is green. Landing the mechanism as a part is sound in principle: after it, no new one-time-credential copy reaches the ledger, and the residual is the ledger's existing copies of still-outstanding one-time credentials, admin-readable until each is consumed or expires (minutes to a day by class), beside the pre-upgrade signing-key and share-link copies the notice covers. Whether and how the invalidation half lands is the maintainer's decision; this record designs nothing. The card stays open either way.
  3. Cross-lane edits against the [PM seat] domain:services · seat 2 — ⏳ vacant #21118 declarations: plugin-auth (internal-field-readback.ts, objectql-adapter.ts, sso-client-secret.ts), plugin-sharing (object, service, routes; the sharing-service.ts interface widened in-package) and plugin-approvals (object) match 5942902099 plus 5943032006; the declared auth-plugin.ts invalidation wiring was withheld, as reported. packages/runtime (① above) is edited by nobody and declared by nobody; the fix needs a declaration on the owning seat before this PR enqueues.
  4. The four out-of-scope notes:
    • a formula or title over an internal column re-serves it (the read path evaluates formulas before the strip) and no authoring check refuses it: a metadata-authoring trap by Prime Directive chore: version packages #10's definition, so a filed card rather than a note — escalated to the seat;
    • the share-link create route answers its creator with the hash: the plugin's own write response of a creator-chosen password; a note is acceptable, and a domain:services card to drop the column from that response is the tidier close — the seat's call;
    • the console's password_protected projection is never served: objectui-side, the note stands;
    • the dormant cached rotation-replay response: the note stands; the readback table's comment should name it so enabling the reuse interval does not strip it silently — the seat's call.
  5. Other sys_audit_log writers outside the mirror (plugin-audit/src/auth-event-audit.ts, plugin-auth/src/admin-import-users.ts, service-settings/src/config-change-audit.ts) write their own event payloads, not object snapshots, and name no census column — no finding, noted for completeness.

Implemented-by: claude/issue-21197-internal-credentials-ledger
Reviewed-by: session_017xfMoEjKUuSh2xYB8sCozp

VERDICT: FAIL


Generated by Claude Code

…an internal column, and the dispatcher's share-link probe reads the password hash through it

- objectql: `readInternalColumn` (secret-fields.ts, exported from both
  entries) recovers one internal column for rows a consumer already holds.
  Stripped-versus-unset is read off the registered declaration the strip keys
  on, recovery goes through the privileged accessor, and it fails closed where
  the strip ran and the value cannot be recovered. Unit-pinned on a real
  engine.
- plugin-sharing: its share-link helper delegates to it, with no second copy.
- runtime: the share-link resolve probe recovers the password hash through
  it, so a protected link answers password-required rather than the
  unknown-link shape. Pinned on a real engine.
- changeset: objectql minor, naming both new exports; runtime patch.

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
claude added 2 commits October 2, 2026 04:48
…ny field and refuses what it does not implement

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
…d of erasing them

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1704f949d93f62417872e3404349787b41b24390
Local-runs: none

Re-review of the fix round after FAIL 5944831998 (head 03a77fb1b6). Inputs: card #21197 (body and all twelve comments, among them the ruling 5942811916, the re-claim 5942885323, the amendments 5943017963, 5943028352 and 5946149793, the os-dev-report 5944632767), the #21118 notes 5942902099, 5943032006 and 5946157490, the #6024 note 5946153076, the previous record 5944831998, PR #21301 (body, 34-file list, the delta 03a77fb1b6..1704f949d9 as four commits and ten files, the net diff for context), tree reads at the head through git show / git grep, and the head's check-runs read last (time below). Classes and positions only; no value is quoted. The ① judgments of 5944831998 on the parts the fix round did not touch (the ledger mirror, the census declarations, the plugin-auth readback rows and adapter ordering, the legacy SSO migration, the plugin-sharing service and route sites, titles and highlights, the lookup-only columns, the A1 to A10 ablations) stand: the delta touches none of those files, and nothing in it changes what they were judged on.

① Derived judgments

The missed consumer, packages/runtime/src/domains/share-links.ts resolve probe (L170 to L224) — resolved, right. The probe no longer reads the hash off the generic engine.find('sys_share_link', …) row. It recovers it through objectql's readInternalColumn(engine, 'sys_share_link', [row], 'password_hash'), guarded by live && engine; live implies row, and row came from that same engine, so the [null] arm is reachable only when the password arm is already dead — no fail-open through the ternary. The helper decides stripped-versus-unset off the registered declaration (below), so a declaration saying the strip ran plus an absent value is never read as "no password"; what it cannot recover it throws, and the throw lands in the handler's own catch (L331 to L367), which answers deps.errorFromThrown(err, 500) — an error, never an open link; invalidOrExpired() is reached only after the password arm. The 410 and SIGN_IN_REQUIRED arms are unchanged. The import is from @objectstack/objectql/core, a declared runtime dependency. Its pin share-links-internal-hash-probe.test.ts is on a REAL engine: ObjectQL over SqlDriver (better-sqlite3, in-memory) with the shipped SysShareLink definition, a real ShareLinkService, the dispatcher's real errorFromThrown, driving handleShareLinksRequest itself. It asserts the generic read carries no hash, an unknown token answers 404 INVALID_OR_EXPIRED, a protected link without a password 401 NEEDS_PASSWORD, with a wrong one 401 WRONG_PASSWORD, and with the right one 200 carrying the record and the token — the four answers the old vi.fn double could not see. Not pinned on this route: the throw-to-error arm; it is pinned at the helper (below) and the catch is the route's existing one.

The new helper readInternalColumn (packages/objectql/src/secret-fields.ts L298 to L379) — right, additive.

  • Stripped versus unset: collectInternalReadFields(engine.getSchema?.(object)).includes(field) — the SAME collector the engine's strip keys on (omitInternalFields, engine.ts L8821 to L8830, over the same registry getSchema fronts), not a re-derived flag read; the old plugin-sharing copy read fields[field].internal === true directly, which the collector reproduces exactly (secret-fields.ts L255 to L263), so nothing moved.
  • Recovery: one batched resolveInternalField(object, ids, field) for every missing row, in row order, null for an unset column, null for a row deleted between the read and the dereference (the caller treats it as gone; in the runtime probe that falls to the closed arms, not the open one).
  • Fail-closed: declared and missing with no accessor, or a row with no id, throws; it never answers "unset" where the declaration says the strip ran. One boundary of the guarantee, stated and pinned rather than hidden: a handle with no getSchema reads as "cannot have stripped" and answers null. That is the old helper's identical posture (judged right in 5944831998), and a handle whose find produced the row on a real engine has getSchema.
  • Not a generic read bypass: it recovers only a column the registered object declares internal, for rows the caller already holds; the accessor itself refuses any other field (INVALID_FIELD, engine.ts L9098 to L9113) and was already public on the engine, so the helper adds no privilege.
  • plugin-sharing's readShareLinkInternalColumn (share-link-service.ts L85 to L92) is now a one-line delegation with its two column names as the narrowed type; no second copy of the rule remains. The parameter type, the Pick of SharingEngine on resolveInternalField and getSchema, is structurally the duck-typed InternalColumnSource.
  • Surface: one function and one parameter type, re-exported from index.ts and core.ts beside collectInternalReadFields; secret-fields.ts was already in the /core closure. No existing export changes. Additive and nothing more.
  • Unit pin read-internal-column.test.ts: a real ObjectQL (the strip and the accessor are the engine's) over an in-memory driver that honours equality, $in, $and and limit and refuses any operator it does not implement. Four cases: the generic read strips and the helper recovers in order with one accessor call; a row carrying the column answers without the accessor; undeclared means unset (two shapes) with no accessor needed; fail-closed on no accessor and on no id. Every branch of the helper is covered.

The two test fixes — neither weakens an assertion.

  • packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts: the storage leg now reads sys_two_factor at driver level, below the strip, which is the layer the question ("what does the column HOLD") is about. The diff changes the row source only; both assertions (a value is stored; no revealed code appears in it) are byte-unchanged. Nit, not a finding: the test reaches the engine's private getDriver (engine.ts L9251) through its any-typed handle where the public twin getDriverForObject (L17975) exists — the seat's call.
  • packages/plugins/plugin-security/src/private-credential-row-scope.test.ts: the row-scope assertions (credentialTables(...) equal to EVERY_ROW(), both sorted id lists; every persona's zero-row and by-id-null cases) are untouched. The admin control now asserts the row by id and that the generic read carries neither credential column — stronger than before on the generic path; it no longer asserts the value there, and the accessor is context-free, so asserting it under the admin would have proved nothing about the admin. The adapter control exercises "filtering on an internal column still finds the row": engine.find('sys_verification', { where: { identifier: … } }) on the now-internal column must return exactly the member's row by id, the generic rows carry no value, and the value is proven through engine.resolveInternalField; the key row by id, no private_key, and the material through the accessor equal to the fixture. The by-identifier lookup is actually exercised, on the real SecurityPlugin plus ObjectQL over a real SqlDriver, in all three deployment shapes.

The dev's repo-wide classification of generic readers — not among the readable inputs; spot-checked instead. No comment on the card (twelve) or on the PR (two) carries that classification at the read time, and the PR body is at the previous head. git grep at the head over non-test source for engine reads of each census object: sys_share_link — the plugin's three service sites and its route probe (through the delegating helper) and the runtime probe (fixed above); sys_two_factor — auth-manager.ts L8323, fields: ['id'] only (the admin unlock); sys_sso_provider — a count; sys_oauth_application — auth-plugin.ts L2352, a findOne by client_id that reads id only (the disable toggle); sys_approval_token — approval-service.ts L4496, lookup-only (judged in 5944831998); sys_api_key — core/security/api-key.ts L284, a column already internal before this PR with its own handling; no direct engine reader of sys_verification, sys_jwks, either OAuth token object or the SCIM credential outside the better-auth adapter path the readback rows cover. A property-name grep for the eleven now-internal column names across non-test source finds no reader off a generic row outside the readback modules, plugin-email's existing headers_json seam and service-messaging's existing headers_json seam. No product-flow generic reader of a now-internal column is left that the tree shows; the installed better-auth dist stays outside this checkout, as the prior record said.

Else in the delta. Commits 5c10c7a and 1704f94 are test-only (the fake driver refuses operators it does not implement; typed engine options instead of erasure). The changeset edit is ② below. The PR body was NOT refreshed for the fix round: it still declares @objectstack/objectql at patch, heads its verification at 03a77fb1b6, and its "What changed" names neither readInternalColumn nor the runtime probe. The body is a description, not the diff; it goes to ③.

② Semver level

  • Changeset at the head: five packages minor under the ! marker as before; @objectstack/objectql now minor, naming both new function exports (collectInternalReadFields, readInternalColumn) — the in-tree widening precedent the prior record cited, so the understatement is corrected (the InternalColumnSource parameter type rides unnamed, a nit); @objectstack/runtime patch added — a bug fix in a released package, the Post-Task Checklist's own level. The other levels are unchanged. Clause-②: no (narrowing) at line start. Check Changeset on this head: success.
  • ADR-0087: the marker stays not-required (no-migration-prescription); its reason text now reads "adds exports only" — accurate.
  • Prose: the signing-key rotation and share-link revoke-and-re-mint notices stand; the one-time credential sentence still states the lifetime bound and still does NOT claim the upgrade-time invalidation; the added sentence on the runtime resolve route describes what the delta does. Matches the diff.

③ Boundary flags

  1. FAIL 5944831998, item by item:
    • Item 1, required gates red (Dogfood shard 2/3 on the two-factor file; Test Core 5/6 on the plugin-security file): both files are fixed in the delta (① above). Check-runs on this head, read last at 2026-10-02T05:49Z: every required context is completed / success — Lint & Repo Gates, TypeScript Type Check (workspace, source, consumer and debt-ledger legs), Test Core (roll-up and all six shards; 5/6, the previously red one, success at 05:47Z), Dogfood Regression Gate (roll-up and all three shards; 2/3, the previously red one, success at 05:36Z), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Check Changeset, the part-of check, the claim checks, Spec property liveness and Dogfood Verify CLI are success; no check-run on the head is anything but success or skipped. Nothing is in progress at the read time. Resolved.
    • Item 2, the missed runtime consumer: resolved (① above), on a real-engine pin.
    • Item 3, packages/runtime edited by nobody and declared by nobody: resolved — declared on the domain:cli seat post [PM seat] domain:cli — ⏳ vacant · archive of domain:server seat 1 (#6367) #6024 (5946153076, the runtime probe, its pin and the dogfood test), the plugin-security test on domain:services [PM seat] domain:services · seat 2 — ⏳ vacant #21118 (5946157490), and the claim amendment 5946149793 records the fix round; objectql is in-lane and plugin-sharing was declared in 5943032006.
    • Item 4, the four out-of-scope notes: the formula-or-title-over-an-internal-column trap is dropped by the seat as inheriting the maintainer's ruling on the FLS analog A field denied by FLS (readable:false) is exactly recoverable from a readable formula that references it — measured on showcase_project.budget #9562 (「9562 不处理」); that ruling is outside this brief's input set and was not read here, so the disposition is recorded as the seat's. The share-link create response, the console password_protected projection and the dormant rotation-replay response: the notes stand as 5944831998 left them. The environmental cli integration failure the dev reports on clean main: not in this diff, not reproducible read-only; no finding.
    • Item 5 (other sys_audit_log writers): unchanged, no finding.
  2. The upgrade-time invalidation of outstanding one-time credentials is still not delivered, and the PR still says Part of (the part-of check on this head is green). The residual is unchanged: the ledger's existing copies of still-outstanding one-time credentials stay admin-readable until each is consumed or expires (minutes to a day by class), beside the pre-upgrade signing-key and share-link copies the notice covers; after this PR no new copy reaches the ledger. Whether and how that half lands is the maintainer's decision; this record designs nothing and routes nothing around the refusal.
  3. PR body stale on three points (① "Else"): the objectql level, the verification head and the what-changed list. A seat act before enqueue: send the body you want stored and read it back; it is not a code change and not a FAIL item — the landing is judged on the file list and the changeset.
  4. Cross-lane: every path of the fix round is now on a declaration; no undeclared edit remains.

Implemented-by: claude/issue-21197-internal-credentials-ledger
Reviewed-by: session_017xfMoEjKUuSh2xYB8sCozp

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 05:54
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 2, 2026
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 50e1c65 Oct 2, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21197-internal-credentials-ledger branch October 2, 2026 06:45
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants