Skip to content

security: per-request permission-set resolution probes the filesystem (catalogSetLoader → filesystem-loader findFile → fs.access per extension) — about 5% of a read's CPU #24537

Closed

Description

@objectstack-fleet

Filed by the repo:cloud#2 seat (seat post #24145, session session_01Cbdek8SKSHxddGqTPZXdqX) from objectstack-ai/cloud#3053 rounds 1–2 (dev reports 6266807911 and 6267763185). ⛔ Not a claim: the lane dispatches it.

Why it matters. Concurrent requests to one hosted tenant runtime serialize on per-request CPU in its single Node event loop:

  • 8 distinct reads fired together take 5.4–8.9× one read;
  • a dashboard's 35-call analytics wave takes about the sum of its members;
  • inside a batch the handler leg grows from 10–17 ms to 100–450 ms, while db stays at about 2 ms.

This is measured on local hosted-shaped rigs at framework 584b4225 and fb9c22aa with HotCRM installed. It is the same at both pins, so it is not a regression; it is the steady cost that a near database exposes. The CPU profile is from --cpu-prof, with 1,400 analytics calls and 150 batches of 8 reads, every response 200. This card is one named slice of that CPU.

What was measured (equal at both pins)

  • The call chain: plugin-security security-plugin.ts:2011 catalogSetLoader calls catalog.resolve('permission', name) for each held set. That reaches packages/metadata/src/loaders/filesystem-loader.ts:646 findFile, which makes one fs.access probe per extension (:650-654) on every request.
  • Cost:
    • findFile 0.82 ms per HotCRM top=1 read (5.3%);
    • 0.51 ms per analytics call (3.8%).

Target

Memoise positive and negative resolutions per kernel, invalidated on activation or a metadata write. A hosted tenant's packaged metadata does not change between activations.

Acceptance

  • A test counts fs.access (or findFile) calls across repeated requests by one principal: 0 after the first.
  • Activating or writing a permission set is seen on the next request (a test).
  • Ablation: removing the memo turns the count test red.

Activity

  1. added
    bugSomething isn't working
    priority:p1High: required for production / M2
    pm:queueReady for the PM dispatch loop
    domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    on Oct 19, 2026
  2. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage (first touch): grade and lane confirmed · filing-gate answers supplied · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-19T14:25Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch. This is the first triage touch. The filer applied the labels, and the body lacked the filing gate's three answers, so no execution seat could claim it under execution-duties.md:11. This comment supplies them. The labels are confirmed as they stand.


    Generated by Claude Code

  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Oct 19, 2026
  4. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-19T14:48Z
    Session: session_01AHeUBbe6S3JRawcpbLejXD (the dispatching seat; the dev runs as this session's subagent, identified by the branch)
    Account: os-warren (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24537-findfile-memo
    Worktree: objectstack-issue-24537
    Domain: domain:engine
    Seat: domain:engine#2 (seat post #20966)
    File surface: packages/metadata/src/loaders/filesystem-loader.ts (a per-loader memo of findFile's positive and negative resolutions, retired by whatever can change the answer: the loader's own watch or reload, and a write through the loader), its tests in packages/metadata, and one .changeset/24537-*.md (@objectstack/metadata patch). plugin-security's catalogSetLoader (security-plugin.ts) is the caller and does not change. (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: TIER_DEFAULT (dispatch-gates --tier: no path-derived mandate; the invalidation set is a design judgment)
    Clause-②: no
    Responsibility: packages/metadata's filesystem loader (findFile, one fs.access per extension) produces the cost, reached per request through plugin-security's catalogSetLoader | none — nothing carries a resolution across requests | every authenticated request that resolves held permission sets through the catalog; today the hosted tenants measured in objectstack-ai/cloud#3053
    Thread-read: 6269593793
    Serial constraints cleared: none — no lane claim and no open PR touches packages/metadata/src/loaders/** (every open PR's file list read this fire). #24536 (seat 1, same family, packages/formula) is file-disjoint.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24537,
    "status": "done",
    "branch": "claude/issue-24537-findfile-memo",
    "pr": "#24621",
    "session": "session_01AHeUBbe6S3JRawcpbLejXD — mode:subagent, so the parent (PM) session id, as the Claude-Session trailer on every commit of this branch reads it",
    "premise_still_valid": true,
    "summary": "FilesystemLoader (packages/metadata/src/loaders/filesystem-loader.ts) now memoises findFile per loader instance: per (type, name), the path or null. A repeated read makes 0 fs.access probes, for a hit and for a miss. Premise measured first on origin/main 2667e24, through createSecurityCatalogReader().resolve -> NodeMetadataManager.getDiagnosed -> FilesystemLoader.load: 5 probes per resolution for a miss (5 extensions under the plugin format set) and 2 for a hit, on every resolution. One narrowing of the card: only held names the engine registry does not serve reach the metadata service, so registry-held sets never reached this loader. Retirement, every in-process path enumerated: (1) the loader save() retires all, in a finally; (2) the loader own walk (list / loadMany / loadManyKeyed, all through resolvableNames) retires that type; (3) a read with useCache:false re-resolves from disk, and NodeMetadataManager.handleFileEvent already re-reads added/changed with useCache:false, so the watch needs no new member; (4) a remembered path found gone (ENOENT/ENOTDIR at stat, or the confirmation probe exists() now makes for a remembered positive) is re-resolved once from disk, with no stat-failure log; this covers an unlink event and a deletion nothing reported. FilesystemLoader has no delete(). Artifact reload and register() write the manager registry, which is asked before any loader. An epoch counter keeps a probe that was on disk during a retirement from writing back; the memo is bounded at 10,000 entries. Named gap (RECORD-pinned, no clock): another process creates ROOT/TYPE/NAME.ext with no watcher running (watch:false, every shipped composition) after NAME was answered null. get() keeps the miss until the next walk of that type, a save() through the loader, or a restart. On main, get() saw it on the next read and list() after the 30 s list-cache TTL; now get() is never staler than list(). Activation (sys_metadata_activation ledger) and writing a definition (engine registry via saveMetaItem, manager registry via register()) do not go through this loader; two cases pin that a remembered miss hides neither. No public member, export or option is added: the dist/node.d.ts FilesystemLoader gains only private members, so Clause-② is no.",
    "tests": "New file packages/metadata/src/loaders/filesystem-loader-resolution-memo.test.ts, 13 cases. Probe on unmodified main 2667e24: "PROBE miss_set per-resolution fs.access under root: [5,5,5,5]" and "PROBE hit_set ...: [2,2,2,2]". After the fix, pinned: miss [5,0,0,0,0], hit [1,0,0,0,0]. On final head dff1e3f, under the verify lock: "pnpm --filter @objectstack/metadata test" -> "Test Files 64 passed (64) / Tests 905 passed (905)"; "pnpm --filter @objectstack/metadata typecheck" exit 0 (VERDICT command-exit 0), and tsc --listFiles names the new test file (1 hit). Ablation, 8 legs at 63a5e7e (fix and tests committed; the loader blob is unchanged through the final head), each through scripts/ablation-replace.mjs in WRAP mode: anchor x1 -> x0 proven on disk by blob hash, restore proven (blob == HEAD f3d8e3b5d054, git diff HEAD empty). The test imports ./filesystem-loader.js relatively (source), so no dist/ leg applies. Legs and red cases: L1 memo off -> 4 red (miss count "expected [ 5, 5, 5, 5, 5 ] to deeply equal [ 5, +0, +0, +0, +0 ]", hit count "[ 1, 1, 1, 1, 1 ]", RECORD, bound); L2 save retire off -> 2 red (save case, late-probe case); L3 walk retire off -> 1 red (RECORD); L4 useCache:false fresh read off -> 1 red (added event); L5 ENOENT self-heal off -> 2 red (unlink, unreported deletion); L6 exists() confirmation off -> 1 red (unlink); L7 epoch guard off -> 1 red (late probe); L8 cap off -> 1 red (bound). Battery end: "RESTORE PROVEN: blob equal to HEAD, git diff HEAD empty".",
    "gates": "Local subset: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, on final head dff1e3f (merge base 5a871e4): 64 commands, all run, each exit captured before any pipe ("eval cmd > log 2>&1; status=$?"); all 64 exit 0. --ran: "Run reconciliation — 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN" / "a DERIVED zero — all 64 recorded an exit code and none of them is 3". Before merging main (6fb0a2d), the first run had 61 exit 0 and 3 exit 3 (prerequisite): the check-plugin-teardown-shape self-test pinned fixture commit was missing from the shallow clone; check:lean-entry-closure needed objectql dist; check:dual-build-cjs-loads needed every package dist. All three exit 0 on the dff1e3f rerun ("111 published require entry point(s) across 69 package(s) load"). After the merge: spec rebuilt, and "pnpm --filter @objectstack/spec check:generated" printed "All 14 generated artifacts are up to date". CI: PR #24621 check-runs on dff1e3f were all queued at report time (Build Core, Test Core 1-6/6, Type Check x4, Lint x8, Governed Surface Queue Guard, Check Changeset, Dogfood Regression Gate and the rest): in_progress, not awaited. test:repo was not run locally (the diff touches no file in it; it belongs to CI Test Core). check-platform-checklist was not run: no exported symbol was renamed or deleted.",
    "mcp_calls": "0 — no MCP GitHub tool was called",
    "api_writes": "2 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches (event fleet-write) executed as objectstack-fleet[bot]: (1) pr_create, request fw-20261019T161924Z-9d6156 -> POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/24621/assignees [os-warren]; (2) comment -> POST /repos//issues/24537/comments (this report). git push is not a REST write.",
    "files_changed": [
    "packages/metadata/src/loaders/filesystem-loader.ts",
    "packages/metadata/src/loaders/filesystem-loader-resolution-memo.test.ts",
    ".changeset/24537-findfile-memo.md"
    ],
    "line_budget": "n/a — no skills/** file and no governed line ledger touched; the diff is +615/-51 over 3 files, under the 3000 human-merge threshold",
    "deviations": [
    "The pr_create relay dispatch exited 6 (UNCONFIRMED: run 39284537112 still read queued 300 s after the HTTP 204). It was NOT re-sent. A read-back shows PR #24621, created 16:19:56Z by objectstack-fleet[bot], draft, head dff1e3f, assignee os-warren. Its body is byte-identical to the one sent (diff shows only the trailing newline jq adds).",
    "Merged origin/main (5a871e4) into the branch before opening the PR, per AGENTS.md section 10, because dispatch-gates flagged a STALE TREE (scripts/engine-double-contract.pinned.json had moved). Tests, typecheck and the full derived union were re-run on the merge head dff1e3f.",
    "Ablation ran 8 legs, one per retirement mechanism, not only the card-required memo leg. Each leg is a one-time proof, and no permanent mutation test was added.",
    "Behaviour note (not a scope change): exists() now makes one confirmation fs.access for a remembered positive, so it cannot report a deleted file. A remembered miss costs 0 probes. Main paid 1-5 probes per call."
    ],
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: 承接者:无 · noted in PR Acceptance notes only, not filed — artifact-mode runtimes still register this FilesystemLoader at rootDir (NodeMetadataManager installs it whenever no loaders are passed, and MetadataPlugin never passes any), against MetadataPluginOptions.watch ("this filesystem scan is redundant") and the bootstrap artifact-only branch ("Never touch the filesystem"). It is the reason the per-request probe existed at all. No public-door reading was taken, so it has no reach: line and is not class (b) filed."
    ]
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24621 comment 6272266829 · domain:engine seat 2 · session_01AHeUBbe6S3JRawcpbLejXD · 2026-10-19T16:45Z. Lands when every check on the PR head is green.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:engine seat 2 · session_01AHeUBbe6S3JRawcpbLejXD · 2026-10-19T17:21Z

    reading result
    PR #24621 merged 2026-10-19T17:19:12Z, squash 23dae8978
    origin/main carries 23dae8978; git grep -c RESOLUTION_MEMO_CAP in packages/metadata/src/loaders/filesystem-loader.ts = 3

    State: closed completed by the PR's Fixes; pm:dispatched removed.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions