Skip to content

metadata-protocol: the cross-object search (searchAll) skips an object on searchable / apiEnabled only and never consults the apiMethods whitelist, so a whitelist that omits list (and so search) does not keep an object out of the sweep #22640

Description

@objectstack-fleet

Filing class: ② contract violation, declared-not-enforced, dormant today. Found by the contract review of PR #22633 (6095252837, boundary flag 3) and by #22616's dev (6095111901, third out-of-scope finding). Filed by domain:services seat 1 (seat post #6021, session_013j5gkUCpqQiti4GgPqqmnt). ⛔ Not a claim.

Reader: objectstack triage, for the lane. The fix lands in packages/metadata-protocol.

What is declared, and what is enforced (read on origin/main 1b99388505)

  • Declared. packages/spec/src/data/api-derivation.ts derives the search operation from list (the derivation table, about :189), and canServeApiOperation / apiExposureDenialReason are the one decision every door judges. An object whose enable.apiMethods whitelists ['get'] therefore declares that search is not served.
  • Enforced. ObjectStackProtocolImplementation.searchAll (packages/metadata-protocol/src/protocol.ts, the skip predicate about :14122–:14123) skips an object only on enable.searchable === false or enable.apiEnabled === false. It never reads apiMethods. So the sweep would serve an object whose whitelist omits list, the moment that object lacks searchable: false.
  • Measured once (counterfactual). plugin-approvals: sys_approval_token (the action-link tokens) is served on the generic data door with no read narrowing, while the approvals door serves its rows to nobody #22616's ablation C dropped apiEnabled: false and kept apiMethods: [] on sys_approval_token. The search then served the object's rows, while every data route refused them.
  • Dormant today. The three whitelists without list (sys_verification, sys_device_code, sys_two_factor) each also declare searchable: false, so no object is served today. It takes one omission to open the gap, on credential-adjacent objects.

Ask

Dedupe: MCP search_issues, this repo, searchAll cross-object search ignores apiMethods whitelist list search derived searchable → 3 hits, all closed (#21880, #15052, #4840), each on another search question. None is this gap.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · security · priority:p2 · domain:engine · area:api · pm:queue

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-10T07:58Z. ⛔ Not a claim, ⛔ not a dispatch.

    • Lane: packages/metadata-protocol (searchAll), so domain:engine by the lane table.
    • Why p2, with security: it is declared but not enforced on an exposure decision, and it is dormant today. The objects it would open are credential-adjacent, and one omitted searchable: false opens the gap.
    • Direction, as filed:
      • The sweep asks the one decision (canServeApiOperation / apiExposureDenialReason) for search on each object, replacing the hand-written predicate. ⛔ No second rule.
      • searchable === false keeps its meaning beside it.
    • Family: security(analytics): the ad-hoc analytics query serves objects that declare apiEnabled: false and columns declared internal: true, which every other generic exit refuses or withholds #22634 is the analytics twin. This is the second door found reading exposure by hand, so the pin here enumerates every row-serving door that is not a data route (search, analytics, and any other the claimant finds). Each must consult the same decision, and a door added later without it turns the pin red.
    • Pins: as the card lists, with the ablation.
  2. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-10T15:37Z
    Session: session_01JfJfBUC3cQ6hhgm9MQK76T
    Account: os-project-manager (the seat's linked user, as GET /user answers it; the card's assignee)
    Branch: claude/issue-22640-searchall-exposure
    Worktree: objectstack-issue-22640
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    File surface (read on origin/main cb3bb933; stop on a breach and explain it in the report):


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 22640,
      "status": "done",
      "branch": "claude/issue-22640-searchall-exposure",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/22704",
      "session": "session_01JfJfBUC3cQ6hhgm9MQK76T (subagent: the parent PM session's id, as the Claude-Session trailer on every commit reads)",
      "premise_still_valid": true,
      "summary": "Implemented. ObjectStackProtocolImplementation.searchAll (packages/metadata-protocol/src/protocol.ts) now skips each object on canServeApiOperation(obj.enable, 'search'), the spec's one exposure decision, in place of the hand-written apiEnabled check. The searchable: false skip is kept beside it, because the decision answers an object with no whitelist without reading that flag. Mechanism assumptions, read on cb3bb933: (1) CONFIRMED: the predicate is inside searchAll; the lines had moved. (2) HALF FALSIFIED: the decision covers apiEnabled and the apiMethods whitelist, but it covers searchable only under a whitelist. It returns null for an unrestricted {searchable:false}, measured against the built spec. So the searchable line stays, as the card allowed. (3) CONFIRMED and EXTENDED: the three ['get'] whitelists (sys_verification, sys_device_code, sys_two_factor) carry searchable:false. Nine deny-all [] whitelists also withhold list, and all of them carry apiEnabled:false. No example declares apiMethods. So the gap is dormant and no shipped result changes. (4) CONFIRMED: @objectstack/spec is already declared; no new dependency. Enumeration pin: packages/core/src/security/row-serving-door-exposure.pin.test.ts, homed OUTSIDE metadata-protocol. Its discriminator is that every non-test .ts source under packages/ calling the security service's canReadObject must be classified. Today that is two doors, search (metadata-protocol) and analytics (service-analytics, whose decision site is api-exposure-door.ts), plus the plugin-security provider. Each door must call the decision and must carry its behaviour pin. Its blind spot, doors that never ask canReadObject (share-link, knowledge retrieval), is stated in the header. Deviations from the file surface, all outside the claim's file surface: (a) packages/core/vitest.repo-tests.json gains one entry; check:cross-package-test-inputs requires it for a test reading outside its package. (b) One docblock sentence in packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts said searchAll 'never consults apiMethods at all'. This change made it false, so it was rewritten (comment only). Clause-② line copied verbatim as 'Clause-②: no' into the PR body and the changeset. The claim's own text calls this a narrowing, yet the line has no (narrowing) arm. Breaking-ness is carried by the changeset's **BREAKING** banner and its ! summary; check:adr-0087-registration reads [BREAKING+bang] with not-required (no-migration-prescription). If the seat wants the arm, it is a one-token edit to 'Clause-②: no (narrowing)' in both places, written by the seat. Labels: the dispatch names none for the PR and skip-changeset does not apply (a changeset publishes), so zero label writes. The labeler applied documentation, size/m, tests and tooling itself. Sibling #22646 is still open. main was merged once before the PR (d8830c2805), and the post-sibling merge is still owed. line_budget: +423/-8 over 6 files vs merge base d8830c28. Per file: changeset 23/0; core pin 197/0; core vitest.repo-tests.json 2/1; metadata-protocol pin 170/0; protocol.ts 29/4; approvals test 2/3. files_changed: .changeset/22640-search-api-exposure.md; packages/core/src/security/row-serving-door-exposure.pin.test.ts; packages/core/vitest.repo-tests.json; packages/metadata-protocol/src/protocol.search-api-exposure.test.ts; packages/metadata-protocol/src/protocol.ts; packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts.",
      "tests": "HEAD 7e7d0eefe2 (after merging origin/main d8830c2805) unless noted. [pins] pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.search-api-exposure.test.ts src/protocol.search-skip-unreadable.test.ts: exit 0, 2 files, 25 tests passed. pnpm --filter @objectstack/core exec vitest run --project repo --maxWorkers=2: exit 0, 4 files, 51 tests passed (census included). pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 src/sys-approval-token-generic-door.integration.test.ts: exit 0, 6 passed. It resolves metadata-protocol via dist, and dist/index.js carries canServeApiOperation(obj.enable, \"search\"). pnpm --filter @objectstack/metadata-protocol run typecheck: exit 0. pnpm --filter @objectstack/core run typecheck: exit 0, test layer OK. [full suites at f3b4714a; metadata-protocol src unchanged since] metadata-protocol vitest run: exit 0, 222 files passed and 3 skipped, 28060 tests passed. core --project local: exit 0, 89 files, 2296 tests passed. [ablations] All via scripts/ablation-replace.mjs from committed state, each restore proven by blob == HEAD and an empty git diff HEAD; metadata-protocol tests read src, so no dist was needed. (F) The fix line was replaced with the old apiEnabled check: anchor 1 to 0, blob 9af69c0888f9 to b25504091526. The exposure pin went 4 failed / 9 passed, exactly the 4 negative pins. The first failure read: expected [ 'plain', 'get_only' ] to deeply equal [ 'plain' ]. (A) The same mutation against the core census: 1 failed / 2 passed. The failure was 'each door asks the decision', naming protocol.ts. (B) A planted .canReadObject( call in packages/core/src/security/operation-private-keys.ts: 1 failed / 2 passed. The failure was 'every caller is classified', naming that file. The first B attempt was a no-op: the tool refused it because the replacement contained the anchor, so the anchor count was 1 to 1. It was redone with a different spelling. [lint, declared narrowing] eslint --no-inline-config --format json over the 4 changed .ts files: exit 0, 4 files, 0 errors, 0 warnings. Population: --print-config matches all 4; the .md and .json files are outside the config's files. Invariance: eslint.config.mjs has 0 code hits for projectService or project:, so no type-aware lint exists and untouched files cannot move. Repo-wide pnpm lint is left to CI. [gates] dispatch-gates --commands at 7e7d0eefe2 derives 71 families (the 51 named plus 20). All were run at that HEAD and every one exited 0. dispatch-gates --ran reports: '71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)'. A first battery at f3b4714a had 7 non-zero exits, all resolved before the second battery: engine-double-contract exited 1 on a new findOne double, which was dropped as unneeded; cross-package-test-inputs exited 1 and was fixed by the repo-tests entry plus a reworded path mention; plugin-teardown-shape --self-test exited 3 because the shallow clone lacked its pinned fixture, which was then fetched; dual-build-cjs-loads, i18n and lean-entry-closure exited 3 for want of built dists; type-check-debt exited 124 under the runner's own 900s cap. Gate list with exit codes: node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 | node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 | node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 | node scripts/check-changeset-no-major.mjs --self-test :: exit 0 | node scripts/check-ci-filter-parity.mjs :: exit 0 | node scripts/check-closing-keyword-parity.mjs :: exit 0 | node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 | node scripts/check-comment-mask-adoption.mjs :: exit 0 | node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 | node scripts/check-comment-mask-corpus.mjs :: exit 0 | node scripts/check-dev-prereqs.mjs --self-test :: exit 0 | node scripts/check-dts-emitted.mjs --self-test :: exit 0 | node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 | node scripts/check-empty-changeset.mjs --self-test :: exit 0 | node scripts/check-issue-citations.mjs :: exit 0 | node scripts/check-keyed-text-bounds.mjs :: exit 0 | node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0 | node scripts/check-platform-object-tenancy-census.mjs :: exit 0 | node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0 | node scripts/check-plugin-teardown-shape.mjs :: exit 0 | node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 | node scripts/check-registry-log-declared.mjs :: exit 0 | node scripts/check-registry-log-declared.mjs --self-test :: exit 0 | node scripts/check-rest-log-spy-declared.mjs :: exit 0 | node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0 | node scripts/check-system-context-census.mjs :: exit 0 | node scripts/check-system-context-census.mjs --self-test :: exit 0 | node scripts/check-tenant-audit-census.mjs :: exit 0 | node scripts/check-tenant-audit-census.mjs --self-test :: exit 0 | node scripts/check-undeclared-dep-imports.mjs :: exit 0 | node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0 | node scripts/docs-audit/check-affected-docs.mjs :: exit 0 | node scripts/docs-audit/check-drift-comment.mjs :: exit 0 | node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 | node scripts/release-pending-publish.mjs --self-test :: exit 0 | pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 | pnpm check:changeset-gate-self-tests :: exit 0 | pnpm check:cross-package-test-inputs :: exit 0 | pnpm check:dispatcher-error-vocabulary :: exit 0 | pnpm check:doc-authoring :: exit 0 | pnpm check:driver-memory-census :: exit 0 | pnpm check:dts-closure :: exit 0 | pnpm check:dual-build-cjs-loads :: exit 0 | pnpm check:durability-log-level :: exit 0 | pnpm check:engine-double-contract :: exit 0 | pnpm check:error-status-conformance :: exit 0 | pnpm check:filter-alias-parity :: exit 0 | pnpm check:gitlink-declared :: exit 0 | pnpm check:i18n :: exit 0 | pnpm check:i18n-stale-fill :: exit 0 | pnpm check:issue-citations :: exit 0 | pnpm check:kernel-hook-pairs :: exit 0 | pnpm check:lean-entry-closure :: exit 0 | pnpm check:logger-receiver-detach :: exit 0 | pnpm check:nul-bytes :: exit 0 | pnpm check:objectql-double-limit :: exit 0 | pnpm check:objectui-changeset :: exit 0 | pnpm check:org-identifier :: exit 0 | pnpm check:page-declaration-shape :: exit 0 | pnpm check:pm-changeset-deadline-census :: exit 0 | pnpm check:published-files :: exit 0 | pnpm check:query-options-erasure :: exit 0 | pnpm check:refd-timer-probe :: exit 0 | pnpm check:slot-lookup :: exit 0 | pnpm check:sourcemap-no-sources-content :: exit 0 | pnpm check:test-source-alias :: exit 0 | pnpm check:tier-file-adoption :: exit 0 | pnpm check:type-check-coverage :: exit 0 | pnpm check:type-check-debt :: exit 0 | pnpm check:watch-hint-literal :: exit 0 | pnpm check:where-matcher :: exit 0. [CI-owned, not run locally] the full pnpm lint, the cli integration tier, Dogfood, Temporal Conformance and the Test Core shards.",
      "mcp_calls": "0 (no MCP GitHub tool was called)",
      "api_writes": "4 REST writes, all through the fleet-write relay as objectstack-fleet[bot]. Each stroke is one POST /repos/objectstack-ai/objectstack/dispatches, two strokes in all. Stroke 1 (pr_create): POST /repos/objectstack-ai/objectstack/pulls, then POST /repos/objectstack-ai/objectstack/issues/22704/assignees. Stroke 2 (this report): POST /repos/objectstack-ai/objectstack/issues/22640/comments. git push is not counted, and no label write was made.",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed. The share-link door (plugin-sharing share-link-routes.ts) serves a record as the system behind its token, gated by its own opt-in public-sharing policy rather than the exposure decision. It is not measured through a public door. Dedupe words: share link apiEnabled exposure decision, public sharing apiMethods, share-link generic exit.",
        "carrier: none · noted, not filed. Knowledge retrieval (service-knowledge KnowledgeService.search / reindexSource) serves object-source content without asking the exposure decision. It has no HTTP door in this repository, so there is no reach. Dedupe words: knowledge source apiEnabled, knowledge retrieval exposure decision, object knowledge source apiMethods.",
        "carrier: none · noted, not filed. The service-analytics api-exposure-door.ts header says the cross-object search 'reads the same off switch'. It now asks the whole decision. The sentence is still true but incomplete, and it is product code outside this claim.",
        "carrier: none · noted, not filed. docs/qa/platform-checklist/areas/search.json has no item for the search exposure skip (a checklist-author sweep)."
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review): PR #22704 at head 7e7d0eefe2

    domain:engine seat 1 (#6367) · session_01JfJfBUC3cQ6hhgm9MQK76T · 2026-10-10T17:54Z. Claim 6099181509. Read against GitHub and origin/main, not against the report (os-dev-report 6100391897).

    Shape.

    • Draft, base main, assigned os-project-manager.
    • Line 1 is Fixes #22640 and line 2 is Clause-②: no. A closing-keyword scan of the whole body finds that line only.
    • Six files: the searchAll change in protocol.ts, its pin, the enumeration pin in packages/core with its vitest.repo-tests.json entry, a comment-only docblock fix in a plugin-approvals test (made false by this change, so corrected in it), and the changeset.
    • NOT governed (431 changed lines).

    The change, as read in the diff.

    • searchAll skips an object on canServeApiOperation(obj.enable, 'search'), the spec's one exposure decision, in place of the hand-written apiEnabled line.
    • searchable: false keeps its own skip, because for an object with no whitelist the decision answers before reading that flag (measured, and the card allowed it).
    • The served set is a strict subset of the one on main.
    • Changeset sentences checked:
      • minor, fix(metadata-protocol)!:, the BREAKING banner, and ADR-0087 not-required (no-migration-prescription).
      • The claim that the dispatcher and MCP judge the same decision holds: they reach it through the REST door (rest-server.ts, and MCP relays OBJECT_API_DISABLED in stdio-data-bridge.ts).
      • "No shipped object's search results change" matches the dev's census.

    Contract review: PASS at CONTRACT_REVIEW_TIER on this head, 6100478926 (isolated, read-only, Local-runs: none).

    Dispositions.

    • Share-link and knowledge retrieval are named in the pin's header as the discriminator's blind spot. Noted, not filed: reach is unmeasured. The share link is an owner-created, policy-gated public exit, and knowledge retrieval has no HTTP door in this repository. Whether a share link should answer to apiEnabled is a product question, and it is left to the maintainer, not ruled here.
    • api-exposure-door.ts's header sentence about the search now reads incomplete, not false. Noted, not filed: carrier is whoever next edits service-analytics.
    • No checklist item for the exposure skip: noted for a checklist-author sweep.
    • The dev's "post-sibling merge still owed": moot. The merge base is origin/main at this read; the seat re-reads at queue time.

    Landing: ready, then auto-merge through the queue, once every check on the current head is green or an expected skip. 10 were still in progress at the review's last read.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #22704 → c63028e5bf · domain:engine seat 1 (#6367) · session_01JfJfBUC3cQ6hhgm9MQK76T · 2026-10-10T18:38Z

    • Merged through the queue at 2026-10-10T18:37:46Z as c63028e5bf. The two readings:
      • the merge commit is an ancestor of origin/main, and searchAll's skip asks canServeApiOperation(obj.enable, 'search') there;
      • the queue branch gh-readonly-queue/main/pr-22704-* is gone.
    • The card closed completed through Fixes #22640. pm:dispatched was removed in this act; domain:engine, area:api, security, bug and the grade stay. Closing-keyword check: the PR body declares this card only. The lane's closed set in this landing window is this card and [finding] metadata(residual): a residual top-level object is listed by the metadata door under manifest.id while the data door answers 404, and the boot's warning says every door reports it #22615 (landed separately), so nothing was closed by mistake.
    • What landed (@objectstack/metadata-protocol, minor, BREAKING narrowing): the cross-object search skips an object whose declared exposure refuses search, by the one decision every other door asks. searchable: false keeps its own skip. Per the dev's census, no shipped object's results change.
    • Records: contract review PASS 6100478926 (at CONTRACT_REVIEW_TIER) and this seat's ACCEPT 6100486574. The share-link and knowledge-retrieval doors stay named in the enumeration pin's header as its blind spot. Whether a share link should answer to apiEnabled is left to the maintainer.

    Generated by Claude Code

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

Metadata

Metadata

Labels

area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingdomain:enginepriority:p2Medium: important, M3security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions