From a72b827e431ba00765da70c52efbf3c0c02e7409 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 03:04:37 +0000 Subject: [PATCH] docs(qa): checklist items for the 17.7 pre-release security fixes Adds three items and extends two for the rules the 17.7 pre-release follow-up landed: - platform-core.settings-audit-secret-fingerprint (new): the settings audit trail fingerprints a secret-valued setting with the keyed digest or not at all, never the value or an unkeyed hash. - identity-auth.implicit-account-linking-ownership (new): no implicit link to an unverified local user, an unlink is honoured, the explicit signed-in link still works, the platform IdP exception holds only on its OAuth path. - search.global-search-skips-unreadable (new): global search skips unreadable objects and fields instead of failing; row scope still narrows; a term only in a hidden field yields no hit. - access-security.share-link-capability-tokens rev 5: the stored hash never leaves, the X-Share-Password header, no-store on public answers. - integration-system.datasource-credential-refusal-matrix rev 3: A2/A7 recorded as a known environment gap; the unknown-driver clause states the ruled plugin-driver boundary. Claude-Session: https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv Co-authored-by: Claude --- .../areas/access-security.json | 51 ++++++++- .../areas/identity-auth.json | 106 ++++++++++++++++++ .../areas/integration-system.json | 29 +++-- .../areas/platform-core.json | 102 +++++++++++++++++ docs/qa/platform-checklist/areas/search.json | 103 +++++++++++++++++ 5 files changed, 376 insertions(+), 15 deletions(-) diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 8206d54f68d..b512c9220b7 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -1510,10 +1510,10 @@ }, { "id": "access-security.share-link-capability-tokens", - "title": "Share-link capability tokens: anon resolve renders the record minus redactFields, password/audience gates hold, revoke/expire refuse without leaking", + "title": "Share-link capability tokens: anon resolve renders the record minus redactFields, password/audience gates hold, revoke/expire refuse without leaking, the stored password hash never leaves the server, the password travels in the X-Share-Password header, and public answers are never cached", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P2", "surface": "api", "personas": [ @@ -1541,7 +1541,10 @@ "mint audience:'signed_in'; anon resolve → 401 SIGN_IN_REQUIRED; mint audience:'email' and resolve with an email OFF the allowlist → refused", "DELETE /api/v1/share-links/ (revoke); anon resolve → 410 EXPIRED_OR_REVOKED; mint a short-expiry link and, after it expires, resolve → 410 — the record must never appear", "delete the shared record r, then anon resolve the still-live (un-passworded, link_only) token → 404 INVALID_OR_EXPIRED, byte-identical to the answer for an unknown token (fail-closed, #5190: \"that record is gone\" is itself information) — capture it beside an unknown-token resolve for the byte comparison", - "GET /api/v1/share-links?object=F&recordId=r as the minter vs a SECOND member — the list returns only the caller's own links" + "GET /api/v1/share-links?object=F&recordId=r as the minter vs a SECOND member — the list returns only the caller's own links", + "the stored hash never leaves: mint a password-gated link and capture the MINT response, the LIST (GET /api/v1/share-links?object=F&recordId=r as the minter) and a SUCCESSFUL resolve. Search every body for the key password_hash and for any hash-shaped value", + "the password travels in a header: resolve the password-gated link with the password ONLY in the X-Share-Password request header (no ?password=). Then send a wrong value in the header, then the right password as ?password= (the compatibility form). Send a CORS preflight for the resolve route from another origin, with Access-Control-Request-Headers: x-share-password", + "public answers are never cached: on every public resolve outcome above (200; 401 NEEDS_PASSWORD and WRONG_PASSWORD; 404 for an unknown token; 410 after revoke) and on GET /api/v1/share-links//messages (a refusal on open-framework showcase, see knownGaps; the refusal is the answer being scored here), read the response HEADERS. Then read the headers of the authenticated create, list and revoke responses" ], "acceptance": [ { @@ -1579,11 +1582,32 @@ "oracle": "api", "verify": "minter's list contains the token; the second member's list for the same object/recordId excludes it (share-links.ts createdBy pin)", "evidence": "both list bodies" + }, + { + "clause": "the stored password hash never leaves the server: no exit (the mint response, the list, the redemption result) carries password_hash or any form of the stored hash", + "oracle": "api", + "verify": "none of the captured bodies contains the key password_hash or a hash-shaped value of it (share-link-service.ts withoutPasswordHash is the one exit projection). A client reads a link's password state from the resolve route's NEEDS_PASSWORD answer, as before", + "evidence": "the mint, list and resolve bodies, searched in full" + }, + { + "clause": "the password is accepted from the X-Share-Password request header, the preferred form because a header stays out of the request URL: header-only with the right password answers 200 and a wrong header value answers 401 WRONG_PASSWORD. The ?password= query form is still accepted. A cross-origin preflight allows the header by default", + "oracle": "api", + "verify": "the three resolve traces (header right → 200, header wrong → 401 WRONG_PASSWORD, query right → 200) and the preflight's Access-Control-Allow-Headers naming x-share-password (DEFAULT_CORS_ALLOW_HEADERS in plugin-hono-server; a deployment passing its own allowHeaders must add it itself)", + "evidence": "the three traces and the preflight response headers" + }, + { + "clause": "public share-link answers are never cached: BOTH public routes (/:token/resolve and /:token/messages) answer Cache-Control: no-store and Vary: X-Share-Password on EVERY outcome, success and refusal alike. The authenticated create, list and revoke routes do not carry these headers", + "oracle": "api", + "verify": "each public response's headers carry exactly Cache-Control: no-store and Vary: X-Share-Password (200, 401 x2, 404, 410, and the messages refusal); the authenticated list response carries neither", + "evidence": "the header block of every public response and of the authenticated list response" } ], "negative": [ "a resolve that returns the record after revoke/expiry, that includes a redactField, or that leaks another user's tokens in the list, is a FAIL", - "the /:token/messages branch is ai_conversations-only (Cloud/EE) — a knownGap, not a stock clause; do not tick it on open-framework showcase" + "the /:token/messages branch is ai_conversations-only (Cloud/EE) — a knownGap, not a stock clause; do not tick it on open-framework showcase", + "password_hash, or any stored form of the share-link password, in a mint, list or resolve body is a FAIL", + "a header-only correct password answering 401 NEEDS_PASSWORD is a FAIL: the route ignored the header, so a client is pushed back to putting the password in the URL", + "a public share-link response without Cache-Control: no-store, on ANY outcome including a refusal, is a FAIL. A cached answer can serve a password-released record to the next person on a shared browser or proxy" ], "variants": [ "audience link_only", @@ -1593,7 +1617,9 @@ "redactFields stripped", "revoked", "expired", - "record-gone (fail-closed)" + "record-gone (fail-closed)", + "password in the X-Share-Password header", + "password in ?password= (compatibility)" ], "traps": [ "wrong-persona" @@ -1604,7 +1630,14 @@ "packages/runtime/src/route-ledger.ts (share-links rows incl. public resolve/messages)", "packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts", "ADR-0047, ADR-0111 D8, #5190", - "cross-ref access-security.share-link-landing-page — the /s/:token console rendering of this surface (UI half): resolve/password/audience/revoke SEMANTICS are scored HERE, what the page renders of them is scored THERE (one defect, one count)" + "cross-ref access-security.share-link-landing-page — the /s/:token console rendering of this surface (UI half): resolve/password/audience/revoke SEMANTICS are scored HERE, what the page renders of them is scored THERE (one defect, one count)", + "packages/plugins/plugin-sharing/src/share-link-service.ts#withoutPasswordHash (the one exit projection: every copy of a link that leaves the service drops password_hash)", + "packages/plugins/plugin-sharing/src/share-link-routes.ts#SHARE_LINK_PUBLIC_RESPONSE_HEADERS (the plugin mount: no-store + Vary on both public routes; the x-share-password header read)", + "packages/runtime/src/domains/share-links.ts#PUBLIC_RESPONSE_HEADERS (the dispatcher mount: the same headers on every public outcome, including a throw outside the body try)", + "packages/plugins/plugin-hono-server/src/adapter.ts#DEFAULT_CORS_ALLOW_HEADERS (X-Share-Password in the default preflight allow-list)", + "packages/plugins/plugin-sharing/src/share-link-password.ts#hashShareLinkPassword (the stored form is the platform slow password hash; legacy forms still verify and are upgraded on the first successful redemption)", + "pins: packages/plugins/plugin-sharing/src/share-link-password.test.ts ('[#21839] the stored hash never leaves the server', '[#21839] how the password travels in', '[#21839] the public routes are never cached') · packages/runtime/src/domains/share-links-public-cache-headers.test.ts ('[#21839] dispatcher public share-link routes are never cached') · packages/plugins/plugin-hono-server/src/hono-plugin.test.ts ('should allow X-Share-Password by default')", + "content/docs/protocol/kernel/http-protocol.mdx (X-Share-Password in the allowed request headers) · #21839 · PR #21890" ], "history": [ { @@ -1630,6 +1663,12 @@ "date": "2026-10-04", "change": "A5 record-gone leg and step 7 re-pointed to the measured answer: a deleted record's still-live link resolves 404 INVALID_OR_EXPIRED, byte-identical to an unknown token, never 410 RECORD_GONE (resolveToken's #5190 fail-closed record probe — share-link-service.ts loadRecordForServing — returns null, and the route's row probe in share-links.ts falls through to its one generic refusal, invalidOrExpired). Revoke/expiry legs unchanged (410 EXPIRED_OR_REVOKED). Stale clause found by the 17.7 pre-release runs", "ref": "#21735" + }, + { + "revision": 5, + "date": "2026-10-06", + "change": "three clauses added for the rules PR #21890 landed (#21839), with a step and a negative each: the stored password hash leaves the server on no exit (mint, list, redemption); the password is accepted from the X-Share-Password header (the ?password= form is still accepted, and the default CORS allow-list carries the header); and both public routes answer Cache-Control: no-store and Vary: X-Share-Password on every outcome, while the authenticated routes do not. The messages route is scored on its refusal, since its success half stays the Cloud/EE knownGap. Existing clauses, steps and indices are unchanged", + "ref": "#21932" } ] }, diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index 1c2b9345da0..2f0e7ddbb82 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -1987,6 +1987,112 @@ { "revision": 1, "date": "2026-08-30", "change": "authored in the 2026-08-30 coverage sweep (angle 1): the workspace-switch surface had no item. CORRECTED against source from the register's 'single membership → indicator' hypothesis: the indicator is posture-gated (postureHasOrgWall — group/isolated only), so the stock `single` boot renders NO org chrome at all and the whole matrix needs an OS_TENANCY_POSTURE boot; encoded the three-cell affordance matrix, the deliberate full-document reload, the #4486 cache-drop read-follow proof, group-posture labeling, and the forged-switch refusal", "ref": "#sweep-2026-08-30" }, { "revision": 2, "date": "2026-10-05", "change": "clause 6 and step 8 re-pointed (assertion defect, #21784 VF4). The switch fails closed, and the defect is not of the authorization class. better-auth 1.7.3's setActiveOrganization (plugins/organization/routes/crud-org) calls adapter.setActiveOrganization(token, null) before it throws 403 USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION, and plugin-auth mounts the route unwrapped (auth-route-ledger 'sdk'). So the active org becomes none, never the foreign org, and 'unchanged' was the wrong assertion. The verifier's wording is used. The verifier's optional follow-up, wrapping the vendor call to keep the previous org, would be a product enhancement, not this item's concern", "ref": "#21797" } ] + }, + { + "id": "identity-auth.implicit-account-linking-ownership", + "title": "Implicit account linking on external sign-in: no implicit link to an unverified local user; after an unlink, implicit sign-in does not re-link; an explicit, signed-in link still works and lifts the refusal; the platform identity provider's exception holds only on its OAuth path", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "anonymous: the external sign-in caller, before it is anyone", + "local user U, signed up with email + password and NOT verified. A stock boot never challenges a sign-up, so a fresh sign-up is unverified", + "the same user U after verifying their email (the verified-row case)", + "U signed in, linking a provider explicitly from account settings", + "seeded admin: reads sys_account and sys_user to confirm what was and was not written" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the local OIDC provider runtime fixture that identity-auth.linked-accounts-social carries in its fixtures.requires (proved by run #21845): a localhost authorization-code + PKCE OpenID provider, registered through the auth manager's applyConfigPatch from a scratch app plugin before the first auth request. ⛔ Never committed, never a product change", + "that provider's test identity answers with an email EQUAL to U's and email_verified: true. A provider that answers email_verified false is refused by the library's own provider-side check first, and the run then measures that gate, not this one", + "a way to verify U's email through the product: the captured-mail verification loop identity-auth.email-verification-loop drives. ⛔ Never by writing sys_user directly" + ], + "knownGaps": [ + "the platform identity provider's exception (acceptance[4]) is scored from the unit pin. A live run would need the fixture provider registered under the id objectstack-cloud on the OAuth method, and no run has proved that registration yet", + "the operator override account.accountLinking.requireLocalEmailVerified (unset by default; true makes the check strict for every provider; false drops only the verification precondition and keeps the unlink rule) is boot configuration. It is pin-only here, like acceptance[4]", + "if the boot offers no drivable email-verification route, acceptance[1] is scored from the pin. Record which of the two the verdict rests on" + ] + }, + "steps": [ + "apply the local OIDC provider fixture (fixtures.requires). Sign up U with email + password (POST /api/v1/auth/sign-up/email). Premise guard: GET /api/v1/auth/get-session as U shows emailVerified false. A verified U makes acceptance[0] prove nothing", + "implicit sign-in, unverified: in a fresh browser context (no session), POST /api/v1/auth/sign-in/social { provider: '', callbackURL, errorCallbackURL }, then navigate the browser to the returned URL (full-page navigation, not XHR) and complete the provider round-trip. Capture the final redirect URL", + "as admin, read sys_account filtered to user_id = U and provider_id = the fixture provider, and re-read U's emailVerified", + "verify U's email through the product (fixtures.requires), then repeat the implicit sign-in: it lands signed in as U, and a sys_account row for the provider exists", + "as U, signed in: unlink the provider (POST /api/v1/auth/unlink-account { accountId: the sys_account row id }) and confirm the row is gone. Sign out, then repeat the implicit sign-in from a fresh context", + "as U, signed in: link the provider EXPLICITLY (POST /api/v1/auth/link-social { provider, callbackURL } with U's session, then navigate to the returned URL). Confirm the sys_account row is back. Sign out and repeat the implicit sign-in once more", + "explicit link for an unverified user: sign up a second unverified local user W (same provider identity re-pointed to W's email), sign in as W and link the provider explicitly through link-social; then re-read W's emailVerified", + "run the pin: pnpm --filter @objectstack/plugin-auth exec vitest run src/implicit-account-linking.test.ts" + ], + "acceptance": [ + { + "clause": "an external sign-in does NOT link implicitly to an existing local user whose email is unverified: the sign-in is refused with error=account_not_linked (the same code the library's own refusal produces), no sys_account row is written, and the local user stays unverified", + "oracle": "api", + "verify": "the final redirect carries error=account_not_linked; the admin's sys_account read for (U, provider) is empty; U's emailVerified is still false (implicit-account-linking.ts IMPLICIT_LINK_REFUSED)", + "evidence": "the final redirect URL, the empty sys_account read, and the emailVerified re-read" + }, + { + "clause": "a VERIFIED local user still links implicitly and is signed in, so the rule narrows the unverified case only", + "oracle": "api", + "verify": "after verification, the implicit sign-in lands on the callbackURL with no error, the session is U's, and the sys_account row exists", + "evidence": "the redirect, the get-session read, and the sys_account row" + }, + { + "clause": "an unlink is honoured: after U unlinks the provider, an implicit sign-in through it is refused with the same code and does NOT re-create the link, even though U's email is verified", + "oracle": "api", + "verify": "the post-unlink implicit sign-in redirects with error=account_not_linked and the sys_account read for (U, provider) stays empty", + "evidence": "the unlink response, the refused redirect, and the empty read" + }, + { + "clause": "an explicit, signed-in link still works and lifts the unlink refusal: link-social with U's session links the provider again, and the next implicit sign-in succeeds. An explicit link is allowed for an unverified user too, and it does not mark that user's email verified", + "oracle": "api", + "verify": "U's explicit link writes the sys_account row and the following implicit sign-in has no error; W's explicit link writes W's row while W's emailVerified stays false", + "evidence": "both explicit-link traces, the following implicit sign-in, and the W emailVerified read" + }, + { + "clause": "the platform identity provider (objectstack-cloud) keeps its exception, and only on its OAuth sign-in path: it links to an unverified local row (the environment owner the cloud seeds without a mailbox round-trip). An enterprise SSO provider (sso-oidc / sso-saml) registered under the same id gets no exception. The unlink rule applies to the platform provider too", + "oracle": "test", + "verify": "the pin passes the cases 'exempts the platform identity provider from the local-verification precondition', 'binds the platform exception to the OAuth sign-in method, not the provider id alone', 'keeps the platform identity provider exception for an unverified owner-seeded row' and 'honours an unlink for every provider, the platform identity provider included'", + "evidence": "the vitest output naming the four cases" + } + ], + "negative": [ + "a sys_account row appearing for an unverified local user after an implicit sign-in, or the caller landing signed in as that user, is a FAIL: it hands the local account to whoever controls the external identity", + "a refused implicit link that still flips the local user to verified is a FAIL", + "an implicit sign-in that re-creates a link the user removed is a FAIL: it makes the unlink decorative", + "an explicit, signed-in link refused for a user whose provider vouches for the email is a FAIL in the other direction: the rule narrows implicit links only", + "the platform exception honoured for an SSO provider registered under the same id is a FAIL", + "a run whose fixture provider answers email_verified false has measured the provider-side gate; it is not a pass of acceptance[0]", + "running the sign-in steps while a session is still active (the previous persona's cookie) measures a link, not a sign-in: use a fresh context for every implicit sign-in" + ], + "traps": [ + "auth-state-leak", + "wrong-persona" + ], + "automated": { + "kind": "unit", + "ref": "packages/plugins/plugin-auth/src/implicit-account-linking.test.ts (the decision table under 'implicit link decision', the vendor configuration pins, and the end-to-end cases under 'implicit link on external sign-in, end to end': unverified refused with no link and no verification flip, verified links, platform exception, operator opt-out, unlink then explicit link, id-token path, generic OIDC by discovery, unlink record durability)" + }, + "source": [ + "packages/plugins/plugin-auth/src/implicit-account-linking.ts#decideImplicitLink (rule 1: the local row must be verified; rule 2: the platform IdP exception, bound to the provider id AND the oauth sign-in method; rule 3: an unlink is honoured for every provider; the operator override)", + "packages/plugins/plugin-auth/src/implicit-account-linking.ts#IMPLICIT_LINK_REFUSED (account_not_linked, byte-identical to the library's own refusal on the callback redirect)", + "packages/plugins/plugin-auth/src/implicit-account-linking.ts#PLATFORM_IDP_PROVIDER_ID (objectstack-cloud)", + "packages/plugins/plugin-auth/src/implicit-account-linking.ts#recordUnlinkTombstone (the unlink record: written before the unlink, failing it closed; cleared by an explicit link or the user's deletion)", + "packages/plugins/plugin-auth/src/implicit-account-linking.ts#refuseImplicitAccountLink (the validateUserInfo enforcement point; an explicit link is told apart by the server-written OAuth state)", + "content/docs/permissions/sso.mdx (Linking to an existing account: the three rules and the operator override as published) · content/docs/permissions/authentication.mdx", + "#21846 (the maintainer ruling that implicit linking to an unverified local user is a defect to close) · PR #21872 (the fix)", + "sibling identity-auth.linked-accounts-social: the EXPLICIT link round-trip and its mine-view; this item owns when a sign-in may link IMPLICITLY" + ], + "history": [ + { + "revision": 1, + "date": "2026-10-06", + "change": "new: the checklist had only the explicit link round-trip (identity-auth.linked-accounts-social), which predates the rule. PR #21872 tightened implicit linking on external sign-in. This item asserts the four rules the published docs state: no implicit link to an unverified local user, an unlink honoured, the explicit signed-in link kept, and the platform IdP exception bound to its OAuth path. It reuses linked-accounts-social's local OIDC provider recipe rather than forking one. The platform-IdP clause and the operator override are pin-scored, and knownGaps says why", + "ref": "#21932" + } + ] } ] } diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index ad56d8e570c..d5fcdfedc90 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -1522,10 +1522,10 @@ }, { "id": "integration-system.datasource-credential-refusal-matrix", - "title": "A datasource credential can never be authored inline NOR read back: every inline-credential door refuses at publish, and BOTH read doors redact — including legacy alias spellings already sitting in stored rows", + "title": "A shipped driver's datasource credential can never be authored inline NOR read back: every inline-credential door refuses at publish, and BOTH read doors redact, including legacy alias spellings already sitting in stored rows. A plugin driver's config is its author's to keep credential-free; the platform redacts only its fixed credential spellings there and does not guess", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P0", "surface": "mixed", "personas": ["seeded admin", "authenticated non-admin", "anonymous"], @@ -1537,7 +1537,9 @@ ], "knownGaps": [ "The legacy-alias clause tests a row shape the CURRENT parse refuses to create — a stored row from before the aliases moved to `guidance`. It is only reachable by writing the row directly (⚠️ NOT through the runtime wizard: POST /api/v1/datasources refuses an alias-spelled config key 400 DATASOURCE_ADMIN_ERROR — the aliases moved to `guidance`, which refuses them, #8078 — measured on a stock showcase boot with `passwd` and by QA run #21056. The run planted its rows with a direct sys_metadata write while the server was STOPPED, then booted and read through both doors). If the run cannot write such a row, that clause is blocked(fixture) and MUST be recorded — a redaction that has never been tested against the shape it exists for is an untested redaction.", - "`encryptionKey` (turso) is deliberately STILL WRITABLE while being redacted on read (#8081 scope item 4). Scoring it as a write refusal is a false fail; the correct expectation is write-accepted + read-redacted." + "`encryptionKey` (turso) is deliberately STILL WRITABLE while being redacted on read (#8081 scope item 4). Scoring it as a write refusal is a false fail; the correct expectation is write-accepted + read-redacted.", + "acceptance[1] (the credential-bound datasource actually connects) and acceptance[6] (judged by a connect probe after the echoed-mask round-trip) need a reachable, credential-protected database of a SHIPPED driver (postgres, mysql or mongo). Stock showcase and the QA container provide none, and every run so far recorded both clauses blocked(environment) (#21784, #21845). No recipe is proven yet. A run that has such a database (for example a postgres:16 service like the one ci.yml's live-PG job starts) binds external.credentialsRef to it and scores both clauses. A run without one records both blocked(environment). ⛔ Never score either clause from a successful publish alone, which proves the paved path is accepted, not that it connects. The stored-credential half of acceptance[6] can still be read without a database (after the PATCH, the admin door still reports hasSecret and the bound secret handle is unchanged); record that as a partial reading, separate from the connect half", + "the plugin-driver boundary is RULED (#21921, docs note in content/docs/data-modeling/drivers.mdx): for a driver the platform ships no contract for, the platform neither validates config nor guesses which of its keys are credentials. Keeping credentials out of a plugin driver's config, through external.credentialsRef, is the plugin author's responsibility. Only the platform's FIXED credential spellings are redacted there by name (acceptance[5]). A credential under any other key of a plugin driver's config being stored and served to administrators as written is that boundary, never a finding" ] }, "steps": [ @@ -1547,7 +1549,7 @@ "read the published datasource back through the datasource-admin door (GET /api/v1/datasources)", "read the SAME datasource back through the metadata door (GET /api/v1/meta/datasource/) — a different code path with its own redaction hook", "plant a stored row carrying a legacy alias spelling (passwd / pwd / token / jwt / auth_token / authtoken) and read it back through both doors", - "author a datasource for a driver the platform ships no contract for, carrying a canonically-spelled credential key, and read it back", + "author a datasource for a driver the platform ships no contract for, carrying a canonically-spelled credential key (password or authToken) AND one non-canonical credential-looking key (for example apiSecret), and read it back. The second key observes the ruled boundary; it is not a redaction target", "for turso specifically, write `encryptionKey`, then read it back through both doors", "grep the full response bodies of every read above for the planted cleartext values" ], @@ -1583,10 +1585,10 @@ "evidence": "the planted row + both reads" }, { - "clause": "an UNKNOWN driver's canonically-spelled credential keys are redacted by NAME — an unrecognized driver means 'nothing to check against', never 'nothing to protect'", + "clause": "an UNKNOWN (plugin-contributed) driver's config is redacted on read by NAME for exactly the platform's fixed credential spellings: the canonical keys (password, authToken), the former alias spellings, and a URL-embedded credential. Nothing else is judged: the platform does not guess which other keys of a plugin driver's config hold credentials, so a non-canonical key reads back as written (ruled boundary, #21921)", "oracle": "api", - "verify": "read back the unknown-driver datasource; the canonical credential key must be redacted despite no shipped contract for that driver", - "evidence": "the response body" + "verify": "read back the unknown-driver datasource through both doors: password / authToken are absent and a URL userinfo password is stripped, although no contract exists for that driver. The non-canonical key from step 7 reads back as written: record it as the expected boundary, not as a FAIL", + "evidence": "the response bodies from both doors" }, { "clause": "the echoed-mask write guard holds: writing the mask value back verbatim is treated as 'unchanged' and does NOT overwrite the stored credential", @@ -1606,7 +1608,7 @@ "a door that refuses at build but accepts through the runtime admin path (or the reverse) — the two-door disagreement that lets a credential in the back way", "a redaction applied on the datasource-admin door but not the metadata door: the two share no dependency except spec, which is precisely why the credential-key definition was centralized there, and precisely how they could drift", "an alias-spelled credential surviving redaction because the redactor derived its key set from the current schema shape only", - "an unknown driver's credential served in cleartext on the grounds that no contract declares it a credential", + "an unknown driver's CANONICALLY-spelled credential (password, authToken, a former alias, or URL userinfo) served in cleartext on the grounds that no contract declares it a credential. ⛔ Not a FAIL: a non-canonical key of a plugin driver's config served as written, or the write door accepting a plugin driver's config unvalidated. Both are the ruled boundary (#21921): that config is the plugin author's to keep credential-free", "an echoed mask overwriting the stored credential with the literal mask string", "a refusal that names no replacement — an author blocked from the inline spelling with no pointer to credentialsRef will reach for a workaround" ], @@ -1622,7 +1624,10 @@ "packages/spec/src/migrations/entries/semantic/18.datasource-config-url-query-credential-refused.ts · 18.datasource-config-mongo-options-credential-refused.ts · 18.datasource-credentialsref-mongo-url-no-user-refused.ts · 18.datasource-credentialsref-mongo-composed-no-username-refused.ts (its COMPOSED-branch twin — no `url`, no `username`) · 18.datasource-config-options-nested-credential-spelling-refused.ts · 18.datasource-config-postgres-url-unparseable-refused.ts", "packages/spec/src/migrations/entries/retired-keys/17.data__MongoConfig__password.ts · 17.data__MysqlConfig__password.ts · 17.data__PostgresConfig__password.ts · 17.data__TursoConfig__authToken.ts", "#7990 / #8081 / #8126 / #8154 / #8300 (the centralization of the credential-key definition into spec)", - "sibling coverage this item deliberately does NOT duplicate: integration-system.datasource-admin-lifecycle (admin-door lifecycle, 'secret never echoes'), platform-core.settings-hub-roundtrip (settings secrets), records-forms.encrypted-field-behavior (ADR-0100 field-level masking)" + "sibling coverage this item deliberately does NOT duplicate: integration-system.datasource-admin-lifecycle (admin-door lifecycle, 'secret never echoes'), platform-core.settings-hub-roundtrip (settings secrets), records-forms.encrypted-field-behavior (ADR-0100 field-level masking)", + "packages/spec/src/data/driver/common.zod.ts#CANONICAL_CREDENTIAL_KEYS (the fixed canonical spellings redacted by name for a driver with no contract)", + "packages/spec/src/data/datasource-credential-redaction.ts#redactableConfigKeys (for a contractless driver: the canonical keys + the former aliases; URL credentials are scrubbed by value)", + "content/docs/data-modeling/drivers.mdx (the plugin-driver boundary: the platform does not guess which config keys hold credentials; keeping secrets out of config is the plugin author's responsibility) · #21921 (the ruling; #21840 closed as not planned) · #21927 (the docs note)" ], "history": [ { @@ -1636,6 +1641,12 @@ "date": "2026-10-01", "change": "checklist-accuracy findings of QA run #21056. knownGaps said the wizard can plant a legacy alias row; it refuses one 400, so planting takes a stopped-server sys_metadata write. acceptance[6] said PUT the response back; the admin door updates by PATCH only. acceptance[3] names the admin door that carries hasSecret / redactedConfigKeys. steps[4] spells the metadata path GET /api/v1/meta/datasource/. source gains the mongo composed-branch twin and the nested-options spelling entry, two refusal doors it did not list. Note: the URL-branch entry's text still lists the composed branch as 'Deliberately NOT refused'; the twin refuses it since #9147, and that text is a spec registry entry outside this ledger", "ref": "#21060" + }, + { + "revision": 3, + "date": "2026-10-06", + "change": "two re-checks from #21932. (1) acceptance[1] and acceptance[6] (A2 and A7 in run records) have been blocked(environment) in every run (no reachable postgres, mysql or mongo). A knownGap now records the gap: what a run needs, what it may not score from (a successful publish alone), and the partial reading available without a database. No recipe is claimed, because none is proven. (2) The plugin-driver boundary is ruled (#21921, docs #21927): the platform does not guess which keys of a plugin driver's config are credentials. acceptance[5], step 7, the unknown-driver negative and the title now say that only the fixed spellings (canonical keys, former aliases, URL credentials) are redacted for such a driver. A non-canonical key served as written is the boundary, not a FAIL. Nothing about shipped drivers changes", + "ref": "#21932" } ] }, diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 5f8a3fc796c..c066f59c4e1 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -2680,6 +2680,108 @@ "ref": "#sweep-2026-08-30" } ] + }, + { + "id": "platform-core.settings-audit-secret-fingerprint", + "title": "The settings audit trail keeps no fingerprint of a secret-valued setting that a reader can match offline: both ledgers record the platform's keyed digest or none, never the value or an unkeyed hash, so a caller who can only read the audit rows learns that the secret changed and nothing about its value", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "seeded admin (admin@objectos.ai / admin123): writes the secret and reads both audit ledgers", + "a caller who can READ the audit rows but holds no server key: the reader this rule protects against. The rows are the same bytes for every reader, so the run plays this reader by checking the captured rows OFFLINE, with nothing but the value it wrote" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the settings service wired with a crypto provider and the sys_secret store, which is the stock wiring: SettingsServicePlugin binds both on kernel:ready (the same requirement platform-core.settings-hub-roundtrip names)", + "an encrypted settings specifier to write: mail.api_key (type 'password', encrypted: true, mail.manifest.ts), written with provider 'resend' so the manifest's validator accepts the patch", + "plugin-audit mounted, so the generic ledger (sys_audit_log) exists. The per-key ledger (sys_setting_audit) is the settings service's own and is always there" + ], + "knownGaps": [ + "the no-keyed-digest arm is not reachable on a stock boot. A host that builds the settings service with only a CryptoAdapter, or a crypto provider that refuses a keyed digest, records the write with NO fingerprint (sys_setting_audit.new_hash null, the bare encrypted marker in sys_audit_log) and warns once per key, and the write itself still lands. Score that arm from the unit pin (automated.ref); ⛔ never build a custom host to reach it", + "audit rows written before this rule landed keep their old fingerprint, and rotating the data key changes every later fingerprint of the same value. Judge only rows the run itself writes, against one key" + ] + }, + "steps": [ + "generate a fresh random value V1 for this run (never a value used anywhere else). As admin, PUT /api/settings/mail { provider: 'resend', api_key: V1, from_email: 'ops@example.com' }", + "read the per-key ledger: GET /api/v1/data/sys_setting_audit filtered to namespace=mail and key=api_key; capture the newest row (action set, encrypted true, new_hash)", + "read the generic ledger: GET /api/v1/data/sys_audit_log filtered to action=config_change; capture the newest row for mail.api_key (object_name 'sys_setting'; new_value is a JSON string carrying namespace, key, scope and digest; metadata carries encrypted: true)", + "OFFLINE, with no server key: compute the SHA-256 hex of V1 (bare, and with a 'sha256:' prefix) and search both captured rows, every field, for it and for V1 itself", + "write V1 again, then a different value V2, then V1 a third time; re-read both ledgers and compare the fingerprints of the three writes", + "reset the key: PUT /api/settings/mail { provider: 'log', api_key: null } (a null value is the reset; 'log' needs no api_key), then read the reset rows", + "control: write the NON-secret key mail.from_email and compute OFFLINE the SHA-256 of its canonical JSON (the quoted string). Its rows must carry that unkeyed digest. This proves step 4's offline search WOULD have found an unkeyed digest, and that the keyed rule is scoped to secret-valued keys" + ], + "acceptance": [ + { + "clause": "a secret-valued setting's write records on BOTH ledgers a fingerprint of the keyed shape hmac-sha256 followed by 64 hex digits: sys_setting_audit.new_hash, and the digest inside sys_audit_log's config_change new_value (inside the encrypted marker). Neither row carries the value", + "oracle": "api", + "verify": "new_hash matches the shape 'hmac-sha256:' + 64 lowercase hex; the config_change row's new_value.digest is the encrypted marker wrapping that same keyed digest; neither row, in any field, contains V1", + "evidence": "both captured rows, quoted in full" + }, + { + "clause": "a reader of the audit rows who holds no server key cannot confirm a guess: no fingerprint in either row can be reproduced from the value alone. The offline SHA-256 of V1, with or without the sha256 prefix, matches nothing in either row", + "oracle": "api", + "verify": "the step 4 offline search over both rows finds no hit, AND the step 7 control's offline digest DOES match the non-secret row. Without the control, a no-hit proves nothing about the search", + "evidence": "the offline digest values, the two secret rows, and the matching control row" + }, + { + "clause": "the keyed fingerprint still answers 'did the value change, and back to what?': equal values carry equal fingerprints, different values different ones", + "oracle": "api", + "verify": "the fingerprints of writes 1 and 3 (both V1) are equal on each ledger, and differ from write 2's (V2)", + "evidence": "the three fingerprints per ledger" + }, + { + "clause": "a reset records no fingerprint: the reset row carries new_hash null on sys_setting_audit, and the config_change row carries no new_value", + "oracle": "api", + "verify": "the reset rows: sys_setting_audit action 'reset' with new_hash null; sys_audit_log config_change new_value null", + "evidence": "the two reset rows" + }, + { + "clause": "non-secret settings are unchanged: a non-encrypted key keeps the unkeyed digest of its canonical JSON on both ledgers. The keyed rule applies to secret-valued keys only", + "oracle": "api", + "verify": "mail.from_email's rows carry the SHA-256 of the quoted JSON string, computed offline in step 7", + "evidence": "the control rows and the offline digest" + }, + { + "clause": "the no-keyed-digest arm records no fingerprint, warns once per key, and never refuses the write: never an unkeyed fallback, and a ledger never vetoes a settings save", + "oracle": "test", + "verify": "run pnpm --filter @objectstack/service-settings exec vitest run src/settings-audit-secret-digest.test.ts: the cases 'legacy inline-adapter path with no provider records no fingerprint and reports it once' and 'a provider that refuses a keyed digest leaves the write intact and records no fingerprint' pass (the stock boot cannot reach this arm, see knownGaps)", + "evidence": "the vitest output naming both cases" + } + ], + "negative": [ + "an unkeyed digest of a secret-valued setting (a plain SHA-256 of the value, with or without a prefix) on EITHER ledger is a FAIL. Anyone who can read the audit rows could confirm a guessed value offline, and settings secrets are short, guessable values", + "the plaintext of a secret-valued setting in any field of either ledger is a FAIL", + "a host with no keyed digest falling back to an unkeyed one is a FAIL; the rule is to record no fingerprint", + "a secret write REFUSED because its audit fingerprint could not be computed is a FAIL: the audit trail never vetoes a settings save", + "a no-hit offline search with no positive control (step 7) is not a pass of acceptance[1]: it does not show that the search would have found an unkeyed digest" + ], + "traps": [ + "seed-data-thin" + ], + "automated": { + "kind": "unit", + "ref": "packages/services/service-settings/src/settings-audit-secret-digest.test.ts (7 cases: 'records the provider keyed digest on both ledgers, not the unkeyed hash of the value' · 'is stable for equal values and distinct for different ones' · 'records no fingerprint on a reset' · the two legacy inline-adapter cases · the refusing-provider case · 'keep the unkeyed adapter digest of the canonical JSON, unchanged' for non-secret settings)" + }, + "source": [ + "packages/services/service-settings/src/settings-service.ts#secretAuditDigest (one helper supplies a secret write's fingerprint on both write branches, the sys_secret path and the legacy inline-adapter path: the provider's keyedDigest, or null with a once-per-key warn; never the adapter's unkeyed digest)", + "packages/services/service-settings/src/config-change-audit.ts#CONFIG_CHANGE_ACTION (the sys_audit_log config_change row: object_name sys_setting, new_value carries namespace/key/scope/digest, never the value; null on a reset)", + "packages/spec/src/contracts/crypto-provider.ts#keyedDigest (the contract text: a secret's audit fingerprint comes from keyedDigest and never from digest; with no keyed digest the trail records none)", + "packages/services/service-settings/src/manifests/mail.manifest.ts (mail.api_key: type password, encrypted: true)", + "#21792 (the maintainer ruling: secret-valued settings are fingerprinted with the keyed digest, never an unkeyed one; non-secret settings unchanged) · PR #21809 (the fix)", + "sibling platform-core.settings-hub-roundtrip: its audit clause asserts that a row EXISTS with new_hash set; this item asserts what a secret row's fingerprint may and may not be. One defect is scored on one item" + ], + "history": [ + { + "revision": 1, + "date": "2026-10-06", + "change": "new: the settings audit trail had no item. platform-core.settings-hub-roundtrip only asserts that an audit row exists, and #21792 was found outside its clauses. The rule PR #21809 landed under the #21792 ruling is now a stock-runnable item: both ledgers carry the keyed digest for a secret-valued setting, the offline check carries a positive control (the non-secret key's unkeyed digest IS found), equal/different values and the reset are asserted, and the no-keyed-digest arm is scored from the unit pin because a stock boot cannot reach it", + "ref": "#21932" + } + ] } ] } diff --git a/docs/qa/platform-checklist/areas/search.json b/docs/qa/platform-checklist/areas/search.json index ec49f9dd00f..dfd7746e4fa 100644 --- a/docs/qa/platform-checklist/areas/search.json +++ b/docs/qa/platform-checklist/areas/search.json @@ -666,6 +666,109 @@ "history": [ { "revision": 1, "date": "2026-08-30", "change": "new item (sweep 2026-08-30, angle 1), authored as a SIBLING of search.console-global-search rather than an extension — writer's call per the register: that item's identity is the GET /api/v1/search record path (hits, grouping, RLS, recents, /search page) and already carries seven clauses, while the palette's other half — the client-side nav corpus (objects/dashboards/pages/reports groups from the active app's navigation), per-type Enter navigation, app switching, theme dispatch, and the evaluateVisibility gate — was wholly uncovered and is a different mechanism (app metadata + client filter, no server search). Grounded in CommandPalette.tsx; the gate-hidden side is blocked(fixture) on stock seeds because no showcase nav item declares a visibility expression and app metadata is not runtime-writable", "ref": "#sweep-2026-08-30" } ] + }, + { + "id": "search.global-search-skips-unreadable", + "title": "Global search (GET /api/v1/search) skips what the caller cannot read instead of failing: an unreadable object is never queried, named or counted; a readable object is searched only on fields the caller may query; row scope still narrows every searched object", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "seeded admin (admin@objectos.ai): the negative control, who sees every object and field", + "signed-up member bound to the contributor position via the qa-contributor-bound-member recipe: a strict subset reader of showcase_invoice (invoice_own_rows), refused some objects outright, and refused the admin-only search columns of sys_user" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "an object W the member is refused at its own door (GET /api/v1/data/W answers 403 for the member) that holds a row matching the run's term T. Pick W and T on the live boot as admin; ⛔ never assume them. A platform sys_* object is the likely W: an unscoped search sweeps every registered object, the platform's own included", + "seeded invoices INV-1001..INV-1012 with INV-1003 invisible to the member, plus one invoice the member creates (as in search.rls-both-personas)", + "a searchable field the member may NOT query that holds a value found in no field the member may query. On stock this is one of sys_user's admin-only search columns (role, ban_reason, last_login_ip, measured by #21879 on a fresh boot). A last_login_ip value is a candidate. Confirm the premise as admin before relying on it" + ], + "provisioning": { + "use": "qa-contributor-bound-member", + "why": "supplies the one persona every clause rests on: a non-admin whose read set excludes some objects (acceptance[0]-[2]), narrows showcase_invoice to its own rows (acceptance[3]) and excludes some sys_user search columns (acceptance[4]). Run as a seeded demo persona instead, the clauses fail the same way the recipe's own notes record: one is refused at the object gate and the other reads everything" + }, + "knownGaps": [ + "#21880 (open): a field-narrowed search is not fully governed by the narrowing when the optional pinyin search companion is on (OS_SEARCH_PINYIN_ENABLED). Run acceptance[4] with the flag OFF (the stock default). A hit seen with the flag ON is recorded against #21880, not filed as a new finding", + "the two #21880 cases (acceptance[3] row scope on a searched object, acceptance[4] a term only in a hidden field) have no end-to-end pin yet; #21879's dogfood pins object-level skipping only, and the hidden-field narrowing is unit-pinned. A run of this item is their only end-to-end reading until #21880 adds them", + "if no stock field satisfies acceptance[4]'s premise (the hidden column holds no value unique to it on this boot), record acceptance[4] blocked(fixture) and score the mechanism from the unit pin. Do not edit sys_user directly to plant one" + ] + }, + "steps": [ + "provision the member (provisioning.use). Premise guards as the member: GET /api/v1/data/W answers 403; GET /api/v1/data/showcase_invoice/ is invisible; GET /api/v1/data/sys_user answers 200 (sys_user is readable, only some of its search columns are not)", + "positive control as admin: GET /api/v1/search?q=T&objects=W,showcase_invoice hits in W. A W with no matching row proves nothing about skipping", + "as the member, an UNSCOPED search: GET /api/v1/search?q=T. Capture the status and the full body text", + "as the member, a mixed scope: GET /api/v1/search?q=T&objects=showcase_invoice,W. Then W alone: objects=W; then a name that matches no object: objects=qa_no_such_object. Capture all three bodies", + "row scope: as the member, GET /api/v1/search?q=INV&objects=showcase_invoice; as admin, the identical query. Compare the hit sets against each persona's readable rows", + "hidden field: as admin, read a sys_user row and pick a value V from an admin-only search column that appears in no column the member may query (premise guard). As admin, GET /api/v1/search?q=V&objects=sys_user must hit (positive control). Then as the member, the identical query", + "run the pins: the dogfood file and the metadata-protocol unit file in automated.ref" + ], + "acceptance": [ + { + "clause": "a member's search over a scope that includes an object they cannot read answers 200 with the readable objects' hits, never a 403 for the whole request; the unreadable object is never named, never hit and never counted (totalObjects counts searched objects only, and no skipped-objects count exists)", + "oracle": "api", + "verify": "the unscoped member search answers 200; its body text contains neither W's name nor any id of W's rows; totalObjects is the number of objects actually searched", + "evidence": "the unscoped response, status and full body" + }, + { + "clause": "an explicit objects= naming an unreadable object answers exactly as a name that matches no object: the mixed scope returns the readable object's hits only, and objects=W alone is byte-identical to objects=qa_no_such_object (empty hits, totalObjects 0)", + "oracle": "api", + "verify": "the mixed body names only showcase_invoice; the W-only body and the no-such-object body are equal", + "evidence": "the three bodies" + }, + { + "clause": "skipping is not a way around the object gate: W stays refused to the member at its own door, and the administrator still gets W's hits from the identical search", + "oracle": "api", + "verify": "the member GET /api/v1/data/W is 403 and the admin search hits in W (steps 1 and 2)", + "evidence": "the 403 and the admin response" + }, + { + "clause": "row scope still narrows a searched object: the member's hits on showcase_invoice are exactly the invoices they can read (their own), INV-1003 is absent, and no count reveals the hidden rows. The admin's identical search returns INV-1003 (#21880 case 1)", + "oracle": "api", + "verify": "the member's hit ids equal their readable invoice ids; the admin's include INV-1003; no total in the member's body exceeds their readable count", + "evidence": "the paired responses and the member's readable-row read" + }, + { + "clause": "a term present only in a field hidden from the caller yields no hit: the member's search for V on sys_user answers 200 with no sys_user hit, while sys_user stays readable and searchable on the fields the member may query, and the admin's identical search hits (#21880 case 2)", + "oracle": "api", + "verify": "member: 200, no hit carrying the row V belongs to; admin: the hit; premise guard and positive control recorded first. Pinyin flag OFF (knownGaps)", + "evidence": "the premise read, both search responses, and the flag state" + } + ], + "negative": [ + "a member's search answered 403 PERMISSION_DENIED for the whole request because one object in scope is unreadable is the defect #21836 fixed: a FAIL", + "W's name, a W row id, or a count that includes W anywhere in the member's response is a FAIL: it discloses an object the member cannot read", + "any difference between the objects=W answer and the no-such-object answer is a FAIL: it turns the search into an existence oracle", + "a hit for a term that appears only in a field hidden from the member is a FAIL (unless the pinyin flag is on, see knownGaps)", + "a member search that 403s because the server picked search fields the member may not query is a FAIL of the field-level half: the caller named no field", + "running the deny side as admin proves nothing (wrong-persona): every member clause runs as the provisioned member" + ], + "traps": [ + "wrong-persona", + "seed-data-thin", + "auth-state-leak" + ], + "automated": { + "kind": "api", + "ref": "packages/qa/dogfood/test/search-skip-unreadable.dogfood.test.ts ('dogfood: global search skips the objects a member cannot read': the walled object refused at its own door, the unscoped search never names it, objects= with it answers the readable hits only and alone equals a no-such-object name, the admin control) + packages/metadata-protocol/src/protocol.search-skip-unreadable.test.ts (12 cases, incl. 'a partial queryable set is handed to the engine as searchFields' and 'an object with NO queryable search field is skipped, unqueried and uncounted'). Row scope (acceptance[3]) and the hidden-field case (acceptance[4]) have no end-to-end pin yet, see knownGaps" + }, + "source": [ + "packages/metadata-protocol/src/protocol.ts#searchAll (before an object is queried, the security service's canReadObject is asked with the caller's context and a refused object is skipped; each readable object is searched only on getQueryableFields, handed to the engine as searchFields; an object left with none is skipped; a read failure on a readable object, or an admission check that throws, still fails the search)", + "packages/rest/src/rest-route-ledger.ts (GET /api/v1/search, the route this item drives)", + "examples/app-showcase/src/security/permission-sets.ts#invoice_own_rows (the row scope acceptance[3] reads)", + "#21836 (the defect: one unreadable object failed the whole search) · PR #21879 (the fix) · #21880 (the two cases this item adds, and the open pinyin-companion finding)", + "siblings: search.console-global-search (the console UI over the same route, with an RLS-parity clause) and search.rls-both-personas (row scope on the /data $search path). This item owns what the global sweep does with objects and fields the caller cannot read" + ], + "history": [ + { + "revision": 1, + "date": "2026-10-06", + "change": "new: PR #21879 made global search skip the objects and fields a caller cannot read instead of failing the whole request with 403, and no item asserted it. search.console-global-search drives the same route but only for row-level parity. This item adds the object-level skip (never queried, named or counted; an explicit objects= naming the object answers like no such object; the object still refused at its own door), plus the two cases #21880 lists: row scope still narrows a searched object, and a term only in a field hidden from the caller yields no hit. #21880's open pinyin-companion finding is recorded as a knownGap with the flag-off instruction. The persona reuses the area recipe qa-contributor-bound-member", + "ref": "#21932" + } + ] } ] }