Skip to content

finding(service-settings): a user-scoped settings key resolved with no userId answers with whichever user row the namespace load returns first — measure who reaches it #22168

Description

@objectstack-fleet

Filing gate: ① a product defect, under the "may leak data" exception. reach: is not measured yet, so the first act is to measure it (see below). Reported by #15207's dev (PR #22166, report 6051681390, out_of_scope_findings[4], "reading only, NOT measured"). Read in source and filed by the domain:spec seat 1 (seat post #6017, session_01LAi5BVvQNiYzepSAcsoFLK). ⛔ Not graded or routed here; ⛔ not a claim. It pre-dates PR #22166, which keeps the behaviour row for row.

What the source says (read at PR #22166's head d0477879af; the same shape is on origin/main ef1fcb26a2)

packages/services/service-settings/src/settings-service.ts:

  • The load. With userId === null, loadScopedRows asks sys_setting for namespace with $or: [{ scope: 'tenant' }, { scope: 'user' }]. That is every user's user rows for the namespace. On main the null-user load is { namespace } alone, which also returns every user's rows. The read runs under SETTINGS_SYSTEM_CONTEXT, so no record-level filter applies.
  • The pick. resolveKeyFromRows, for a key declared at scope: 'user', takes rows.find((r) => r.key === key && r.scope === 'user'): the first user row in load order, whoever it belongs to. Nothing compares user_id to a caller.
  • Who calls it. get / getMany resolve a user-scoped key with ctx.userId ?? null. Any caller whose context carries no user id (a service, a job, a system read, an unauthenticated path) therefore gets some user's value, or that user's decrypted value for an encrypted key.

Measure first (the card's first step)

  1. Which user-scoped keys the built manifests declare, and which of them are encrypted.
  2. Which in-tree callers resolve a user-scoped key with no userId, and over which door (HTTP, job, service).
  3. Whether any of those answers reaches someone other than the row's owner.

If no caller reaches it, this is a latent defect: the fix is to answer the user rung only for a caller with a user id. If a caller does reach it, it is a disclosure, and the security floor applies.

Not this card

The tenant rung's rows carry no organization (SETTINGS_SYSTEM_CONTEXT, unclassified sys_setting). That is ADR-0131's territory: D1 / D9, fate per table in C7 #15211, refusal in C8 #15212. It is pointed to on #15211, not filed here.

Dedupe

MCP search_issues, repo-scoped, closed included:

Dedupe words: user-scoped setting without userId · resolveKeyFromRows first user row · settings user rung any user's value

Activity

  1. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: access — a user's settings stay that user's | 缺项 | P1

    Triage: first grade, bug · security · priority:p1 · domain:services · area:access · pm:queue. Measure first, then decide the fix; the "may leak data" exception

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

    Triage: lands in packages/services/service-settings/src/settings-service.ts (the null-user load path) ⇒ domain:services; rationale: packages/services/* is that lane's. Read on main ec8f37c890.

    • Why p1 before measurement: by source reading, a user-scoped key resolved with no user can answer from another user's row. That is the "may leak data" exception, so it is graded on its worst reading until measured.
    • Direction:
      • measure first, read-only: every caller that resolves a user-scoped key with no user, and whether any of them reaches a response another user can see
      • if one does: the null-user path never returns a user-scoped row (it answers the tenant value or nothing), with a pin
      • if none does: record the census here; the seat re-grades and keeps the narrowing as a defensive fix
    • ⛔ Classes, positions and functions only on this card; measurement details that read as a recipe stay out of it.
    • Clause-②: no. Patch changeset for the settings service.
  2. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 · 2026-10-08T05:17Z
    Session: session_01WkL6Eijt432S1Y7ekb6ovQ
    Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22168-settings-null-user-measure
    Worktree: objectstack-issue-22168
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    ⛔ Classes, positions and functions only.

    Round 1 is measurement only, per triage 6052603695. No production change, no commit beyond the empty branch at its base, and no PR. The only write is the os-dev-report comment on this card. The seat decides the fix's shape from the census.

    File surface, read and measured only (at origin/main 6ed0c0f3):

  3. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22168,
    "status": "done",
    "branch": "claude/issue-22168-settings-null-user-measure",
    "pr": null,
    "session": "session_01WkL6Eijt432S1Y7ekb6ovQ — the dispatching PM session (mode:subagent, the parent's id)",
    "premise_still_valid": true,
    "summary": "VERDICT: no in-tree caller reaches another user's value. The mechanism is real (H1 confirmed on a booted kernel), but the built manifests declare ZERO user-scoped keys (11 namespaces, 137 keys, 0 user, 18 encrypted, all global), so the user rung is never walked in any in-tree composition; the defect is latent, per the triage ruling's "if none does" arm: record the census, the seat re-grades, the narrowing stays as a defensive fix. H1 CONFIRMED on a booted kernel (LiteKernel, the real ObjectQL engine and the real settings service): the class the card names holds. A resolve with no user id answers a user row the caller does not own (decrypted, for an encrypted key) through get, getMany and getNamespace, and the load order decides which row. With a user id, each user gets only their own row. The no-engine branch behaves the same. [Seat redaction: the measurement's procedure and its per-case answers are withheld from this public card, per triage 6052603695. The seat holds them.] Mechanism positions: SettingsService.loadRows (null user: where is namespace alone, every user's user rows, read under the system context) and SettingsService.resolveKeyFromRows (user rung takes the first row with scope user for the key, no user_id comparison); reached from get, getMany, getNamespace and through them createClient, resetNamespace and runAction. H2: zero user-scoped keys. Per namespace (effective scope from the service's own derivation, specifier scope else manifest scope else tenant): branding 5 tenant; company 13 tenant; localization 5 tenant; feature_flags 4 tenant; auth 29 global (encrypted: google_client_secret); mail 10 global (smtp_password, api_key); sms 10 global (aliyun_access_key_secret, twilio_auth_token); storage 11 global (s3_secret_access_key); ai 35 global (10 encrypted provider keys); knowledge 10 global (turso_auth_token, ragflow_api_key); lifecycle (objectql, registered at kernel:ready by ObjectQLPlugin) 4 global + 1 tenant. Same answer read from the built dist and from a booted roster with the real ObjectQLPlugin. H3 census, every in-tree null-user resolver, with its door (keys read are all global or tenant): (1) plugin-auth AuthPlugin, kernel:ready hook plus subscribe refresh: get on branding.workspace_name and localization.locale with an empty ctx, getNamespace on auth; (2) organizations membership-policy gate: getNamespace on auth, no ctx; (3) plugin-email EmailPlugin, kernel:ready plus subscribe: getNamespace on mail; (4) service-sms SmsPlugin, same door: getNamespace on sms; (5) service-storage StorageServicePlugin, same door: getNamespace on storage; (6) objectql LifecycleService.loadGovernance, the lifecycle job path: get on lifecycle keys with no ctx and with a tenantId-only ctx; (7) core resolveLocalizationContext, getMany on localization with whatever user its caller holds: plugin-audit's audit-writer locale is the one caller that can pass none (system writes on the CRUD write path). Callers that always carry a user: REST execution context (returns early for anonymous), runtime execution context (gated on a resolved user), current-user endpoints (signed-in user), MCP stdio (a key with no owner is refused at start). HTTP door (GET, PUT, POST under the settings base path): the context comes from resolveAuthzContext, which returns no permissions when it resolves no user, and getNamespace, setMany and runAction run the manifest permission gate before any row load, so no null-user resolve happens over HTTP. Not a resolver: the CLI secret-orphans report reads stored rows through the driver plus listManifests. H4 (the verdict line): for each caller the answer CAN reach people other than a row's owner if the key were user-scoped. (1) is applied process-wide: the auth app name, the default email and SMS locale, and an info log line naming the locale. (2) through (5) become shared policy or a shared transport config, and for an encrypted key the shared credential. (6) is applied across retention. (7) writes the locale into audit summary text that admins read. Measured today: none of them reads a user-scoped key, because none exists, so no answer reaches anyone. Reach opens the moment a manifest declares scope user. The spec allows it (SpecifierScopeSchema, "personal preference namespaces should use user"); the producers are the SettingsServicePlugin manifests option and SettingsService.registerManifest on the live service; the localization manifest's own header names per-user overrides as deferred, and that namespace is the one read by callers (1) and (7). H5: PR #22166 at head c3f5925 changes no answer. Its loadScopedRows null-user filter (scope tenant OR scope user) still returns every user's user rows, the in-memory branch is unchanged, and no hunk touches resolveKeyFromRows. Measured at origin/main 6ed0c0f (BASE); main has since moved to c6fe02d with an empty diff on service-settings, the lifecycle manifest and resolve-authz-context, and still zero user-scope declarations in manifest sources. Re-grade input for the seat: latent, with zero measured reach. The defensive narrowing is disjoint from PR #22166's load-path hunks if it lands at the pick (resolveKeyFromRows answers the user rung only when a user id is present and equals the row's user_id). It touches only the getMany loop lines that PR #22166 carries as context.",
    "tests": "Booted measurement (scratch vitest file, uncommitted, deleted after the run; tree clean, HEAD = BASE 6ed0c0f): OS_VERIFY_LOCK_SLOT=dev-22168 bash scripts/pm/os-verify-lock.sh -c "NODE_OPTIONS=--max-old-space-size=3072 pnpm --filter @objectstack/service-settings exec vitest run --maxWorkers=2 src/scratch-22168.booted.test.ts" printed "Test Files 1 passed (1) / Tests 5 passed (5)" and "VERDICT command-exit 0". The real-ObjectQLPlugin roster gave namespaces 11 (lifecycle included), keys 137, encrypted 18, userScoped [], engineBound true. [Seat redaction: the cases and their per-case answers are withheld from this public card, per triage 6052603695. The verdict per class is in the summary.] Built-dist enumeration (H2): node enumerate-built-manifests.mjs over packages/services/service-settings/dist and packages/objectql/dist (built first under the lock: turbo build --filter=@objectstack/service-settings^... gave 15/15 cached, VERDICT command-exit 0; then --filter=@objectstack/service-settings gave 16 successful, VERDICT command-exit 0). Result: totals {namespaces 11, keys 137, user 0, encrypted 18}. POSITIVE CONTROL on the same built service, a manifest declaring two user-scoped keys (one encrypted) plus one tenant override: both user keys found and the encrypted one flagged. Declaration-shaped searches (git grep at 6ed0c0f, non-test source under packages, examples and apps). H2 static: the scope-user declaration pattern gives 0 hits in the 10 manifest files plus the lifecycle manifest; CONTROL, the same pattern in the same package's test fixtures, gives 14 hits in 6 files. The only non-test hits repo-wide are the resolver itself, a doc comment and an unrelated sys_file insert. H3 call shape: a settings handle calling get, getMany, getNamespace, createClient, runAction or resetNamespace with a literal namespace gives 15 lines: 12 caller lines (core 4, plugin-auth 3, and 1 each in lifecycle, organizations, email, sms and storage), 1 false positive (a Map read in metadata-protocol), 2 checker fixture strings in scripts/check-settings-bind-window.mjs, and the POSITIVE CONTROL, resolveLocalizationContext's getMany on localization, which is hit. Non-literal-namespace variant: 0 additional settings callers; createClient has 0 non-test callers. Producer census: registerManifest has 2 callers (SettingsServicePlugin init with default manifests; ObjectQLPlugin kernel:ready for lifecycle, also the control); SettingsServicePlugin is constructed with no manifests option in the serve capability table, the CLI one-shot plugin and the verify harness. Not run: package test/typecheck suites (no source change), any check:* gate (no diff), a full showcase/dogfood boot (47 uncached packages on a shared box; the LiteKernel boot with the real ObjectQLPlugin plus the built-dist enumeration plus the registrant census stand in for the provisioned roster, which is a declared narrowing). One local side effect: fetching main into an owned ref auto-followed one remote tag into the shared object store (local only, nothing pushed).",
    "mcp_calls": "0",
    "api_writes": "1 — POST /repos//issues/22168/comments (this os-dev-report, via scripts/pm/post-stamped.mjs). git push of the empty branch at BASE is a git op, not REST. Reads: REST GETs of the card, its comments, PR #22166 and its files.",
    "open_questions": [
    {
    "question": "Write half, same class and same file, measured booted: setMany on a user-scoped key with no user id persists a user row with no owner and answers source user. Does the defensive fix cover it?",
    "options": [
    "A: same fix round. setMany refuses a user-scoped key without a user id, a loud typed refusal before any write; the read narrowing then never meets an ownerless user row.",
    "B: narrow reads only and leave the write as it is. An ownerless row becomes a write that reports success and that no reader ever returns.",
    "C: a separate card."
    ],
    "recommendation": "A. Business need: measured zero user-scoped keys, zero in-process settings writers and an HTTP door that always carries a user, so the refusal costs no live caller. Long-term: the spec's scope contract says the resolver scopes reads and writes to the user id; fixing both halves in the service is contract-first, not a consumer patch. AI-error prevention: a loud refusal beats a silent ownerless row an AI-written host manifest would produce unnoticed. Startup focus: a few lines in the same file and round, no new gate, no new surface."
    }
    ],
    "out_of_scope_findings": [
    "carrier: #15211 (ADR-0131 C7), noted, not filed. The tenant-rung pick ignores the tenant too, so LifecycleService.loadGovernance's per-organization retention-override reads would see the one tenant row for every organization. That is the card's own "Not this card" item; this is a reading, not measured."
    ]
    }

  4. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat review: measurement round · seat domain:services#1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T05:43Z

    ⛔ Classes, positions and functions only.

    This review reads the dev's report (6053149206) against triage's direction (6052603695).

    Release: session session_01WkL6Eijt432S1Y7ekb6ovQ · reason: the measurement round is done with no PR, and the fix waits on PR #22166 · destination: back to pm:queue at priority:p2, claimed from the queue after PR #22166 lands. The empty branch claude/issue-22168-settings-null-user-measure, at its base, may be reused or ignored.

  5. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-08T07:58Z
    Session: session_01WkL6Eijt432S1Y7ekb6ovQ
    Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22168-settings-user-rung-owner
    Worktree: objectstack-issue-22168-fix
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    ⛔ Classes, positions and functions only.

    The fix round. It follows the seat's review and Release: (6053221379). PR #22166 has landed, so the serial constraint is cleared. The defensive fix is in two halves, as that review ruled.

    File surface at origin/main a87d8be2. Re-read on the landed shape before building:

    • packages/services/service-settings/src/settings-service.ts:
      • Read half: resolveKeyFromRows (near :1405) answers the user rung only for a caller whose user id is present and equals the row's user_id. A resolve with no user id falls through to the tenant rung, then global, then the default. This covers every caller path into it (get, getMany, getNamespace and what reaches them).
      • Write half, ruled A: setMany (near :1688) refuses a user-scoped key with no user id. The refusal is typed and comes before any write.
    • Tests in service-settings (pins on both halves; control: a caller with a user id reads and writes only their own row, as today), and one @objectstack/service-settings changeset.

    Exclusions:

    Stop on breach; explain in the report.
    Container & model: S, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier, a security card)
    Clause-②: no (narrowing)

    • setMany refuses an input it used to store (a user-scoped key with no user id), which narrows a published service method's accept set. Nothing widens.
      Responsibility: this repository's own code: service-settings answers a user-scoped key resolved with no user id from the first user row its load returns, and stores a user-scoped key written with no user id as an ownerless row | none: no caller-side guard exists, and the HTTP door always carries a user | latent: zero user-scoped keys in-tree today (census 6053149206); reach opens with the first manifest that declares scope user
      Thread-read: 6053221379
      Serial constraints cleared: PR feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less sys_platform_setting (ADR-0131 D7) #22166 merged. Of the open PRs (each file list read), none touches settings-service.ts.
  6. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22168,
    "status": "done",
    "branch": "claude/issue-22168-settings-user-rung-owner",
    "pr": "#22246",
    "session": "session_01WkL6Eijt432S1Y7ekb6ovQ — the dispatching PM session (mode:subagent, the parent's id)",
    "premise_still_valid": true,
    "summary": "Both halves of the seat review's ruling are implemented in settings-service.ts, at head fd24546 on base 0e9371f. READ: resolveKeyFromRows takes the caller's user id and answers the user rung only for a row whose user_id equals it. A resolve whose context names no user has no user rung and falls through to tenant, then global, then the default. get and getMany thread the id through one helper, callerUserIdOf, which treats an absent or empty id as no user. getNamespace, createClient, runAction, resetNamespace and the re-resolves inside setMany and validatePatch reach the cascade only through get or getMany. The engine branch and the no-engine in-memory branch share the pick. WRITE, ruled A: the setMany pre-flight collects every user-scoped key in a batch whose context names no user and throws SettingsValidationError (SETTINGS_VALIDATION; one FieldError per key, code invalid_value, constraint scope user). The throw comes before validatePatch, before any row load for that key and before any write, and it refuses the whole batch. A null reset of such a key is refused too. H1 confirmed on the landed shape: the user-rung pick did not compare user_id, and the null-user load and the in-memory filter both return user rows of every owner. H2 confirmed by pins. H3: no existing settings error class takes the user id or the user scope. SETTINGS_VALIDATION is the class that fits: it refuses the whole batch before any write and already carries a non-value refusal under invalid_value. A new code would need an ERROR_CODE_LEDGER row in packages/spec, which is outside the fence. Reach re-confirmed at 0e9371f: the shipped manifests declare no user-scoped key at manifest or specifier level, the in-process writers are the HTTP routes and the service's own set and resetNamespace, and the HTTP door resolves a user or no permissions before any row load (an admitted API key always carries a userId). So no in-repo caller meets either half; the change is latent and defensive. Fence held: only settings-service.ts, one new pin file and one changeset. The tenant-rung organization comparison is untouched, and packages/spec and the manifests are untouched. The changeset is minor, not patch: Clause-② no (narrowing) is BREAKING, and check-changeset-no-major's level axis requires minor or above for a narrowing. With the PR body as its event it read clean.",
    "files_changed": [
    "packages/services/service-settings/src/settings-service.ts (+84 -12)",
    "packages/services/service-settings/src/settings-user-rung-owner.pin.test.ts (new, 274 lines)",
    ".changeset/22168-settings-user-rung-owner.md (new; '@objectstack/service-settings': minor; line-start Clause-② no (narrowing); adr-0087 not-required (no-migration-prescription))"
    ],
    "pins": "settings-user-rung-owner.pin.test.ts, 65 cases over the engine-bound and in-memory stores. Negative pins: a resolve with no user id never answers a user-scoped row, on get, getMany, getNamespace, createClient and runAction, for an empty context, a tenant-only context and an empty-string id. Also: the fall-through order tenant, global, default; a reset with no user id counts no user row and writes nothing; the user-rung pick compares the owner itself when the store returns more rows than asked; a write of a user-scoped key with no user id is refused (instanceOf, code and fields asserted, no status), stores nothing and emits no change event; a mixed batch is refused whole. Controls: per-user reads and writes, the owner's row above the tenant rung, and tenant and global keys with and without a user id. Red leg on base source (pin file only, fix absent): 34 failed and 26 passed, every negative pin red and every control green. One control was split before commit because it also asserted a no-user read.",
    "tests": "All runs went through scripts/pm/os-verify-lock.sh, slot dev-22168, NODE_OPTIONS=--max-old-space-size=3072. (1) pnpm --filter @objectstack/service-settings exec vitest run --maxWorkers=2 at fd24546: 'Test Files 39 passed (39) / Tests 702 passed (702)', 'VERDICT command-exit 0'. The first attempt failed 11 suites at import, 'Failed to resolve entry for package @objectstack/metadata-core', because the fresh worktree had no dist; that is NOT MEASURED, not a red. After building the dependency closure with turbo build --filter=@objectstack/service-settings^... (15/15 cached, VERDICT command-exit 0) it went green. (2) pnpm --filter @objectstack/service-settings typecheck (tsc --noEmit): 'VERDICT command-exit 0', and tsc --listFiles includes the pin file. (3) Ablation from the committed state: scripts/ablation-replace.mjs in wrap mode inside the lock, plus a bash trap EXIT INT TERM restore by absolute path with a HEAD-blob hash check. Each leg printed anchor x1 to x0, replacement x0 to x1, blob 3b142fbc1421 changed, then 'ok restored: blob == HEAD (3b142fbc1421) and git diff HEAD is empty'. M1, presence check removed from the pick: 39 of 65 failed (the no-user read pins on both stores, fall-through, reset, over-answer); an ownerless legacy row makes this check load-bearing. M2, owner comparison removed: 5 of 65 failed (the over-answer pin on each read path); with the current loaders a present id loads only its own rows, so this is the one pin that holds the comparison. M12, both removed: 39 of 65 failed. M3, refusal disarmed: 8 of 65 failed (the no-user write pins on both stores and the batch pin). No control failed in any leg. Direction observed: red in every leg. (4) Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, run from the worktree at fd24546, derived 64 commands, and all 64 were run. 63 exited 0 on the first pass. pnpm check:dual-build-cjs-loads exited 3, 'PREREQUISITE NOT MET … no dist'. After replaying 71 cached builds (turbo, 71/71 cached, VERDICT command-exit 0) it printed '✓ check:dual-build-cjs-loads — 106 published require entry point(s) across 66 package(s) load', exit 0. Named verdicts: check-adr-0087-registration '✓ … 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition'. check-changeset-no-major: '✓ This diff introduces no major bump'; its level axis is NOT APPLICABLE locally (no PR payload), and with --event carrying the PR body it read '✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and no package … is graded patch'. Reconcile: dispatch-gates --ran with exit codes recorded printed '✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero …)'. (5) Lint, a declared narrowing: eslint --no-inline-config --format json on the two changed TS files gave 2 files, 0 errors and 0 warnings. Population: eslint.config.mjs lints all **/*.ts outside NEVER_LINTED, and both files appear in the JSON. Invariance: --print-config shows no parserOptions.project or projectService, so type-aware linting is off and this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's.",
    "deviations": [
    "Pushed the empty branch at base before the first edit: the definition's write-route probe, which carries no content. The dispatch said push nothing until the fix sits on the red pins; the definition wins on conflict. No pin or fix was pushed before the fix sat on red pins.",
    "Base moved: worktree created at origin/main 0e9371f, not the claim's a87d8be; git diff a87d8be..0e9371f over packages/services/service-settings is empty.",
    "Changeset graded minor, not the patch the triage and review named, because the claim's Clause-② no (narrowing) is a declared BREAKING change, and the launch-window convention and the no-major level axis require minor or above for one.",
    "Two design readings inside the ruling: an empty-string user id counts as no user id, on both halves; a null reset of a user-scoped key with no user id is refused too, with no exemption like the crypto gate's, because the only row it could touch is ownerless and unread.",
    "Two pins added in a second commit (fd24546) after ablation planning showed the two read guards are redundant for the first pin set: an ownerless legacy row, and a store returning more rows than asked. Without them M1 and M2 would each have stayed green.",
    "Attribution: commits carry the model-free trailer pair from AGENTS.md, and the PR body ends with the AGENTS.md session-URL footer, not the harness reminder's model-named trailer and footer form."
    ],
    "mcp_calls": "0",
    "api_writes": "3 relay dispatches, each POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]: (1) pr_create, run 37752489470, which became POST /repos/objectstack-ai/objectstack/pulls (draft, #22246, body read back byte-identical, 7064 bytes); (2) scripts/pm/label-write.mjs --issue 22246 --assign os-bill, run 37752590127, which became POST /repos//issues/22246/assignees (read back: assignee os-bill; the size/m label was set by another actor); (3) this os-dev-report comment via scripts/pm/post-stamped.mjs, which becomes POST /repos//issues/22168/comments. There were 3 git pushes (git ops, not REST): the empty branch, 5597ed9 and fd24546. Reads: REST GETs of the card, its comments and the PR.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none (承接者:无) · noted in the PR's Acceptance notes, not filed. The load with no user id (loadScopedRows and the in-memory filter) still reads user rows of every owner. It is inert for the cascade now that the pick compares the owner, so narrowing the load itself is defence in depth, outside this fence.",
    "carrier: none (承接者:无) · noted, not filed. Ownerless user rows stored by the old no-user write path stay in the store, unread. The service cannot clear one now, because a null write with no user id is refused. There is no in-repo producer, since no shipped manifest declares a user-scoped key.",
    "carrier: none (承接者:无) · noted, not filed; a reading, not measured. resetNamespace on a user-scoped key whose effective value comes from the tenant or global rung writes null at the user rung and counts the key as cleared, while the upper value stays in force. Not filed, for no reach: no user-scoped key ships. With no user id, that path now meets the new refusal.",
    "carrier: none (承接者:无) · noted, not filed. The 'Writes fail loudly' table in content/docs/protocol/kernel/config-resolution.mdx has no row for the new refusal; that file is outside the fence.",
    "carrier: #15211 · the tenant rung's organization comparison is untouched, as the fence required."
    ],
    "cleanup": "Worktree ../objectstack-issue-22168-fix: node_modules removed and git worktree remove succeeded without --force, with a clean tree at fd24546. No dev server or background process was left running. The gate battery's background PID exited, and its foreground wait ended with it."
    }

  7. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat ACCEPT: PR #22246 at fd245469 · seat domain:services#1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T08:55Z

    ⛔ Classes, positions and functions only.

    Checked against GitHub and the branch, not the report's prose (os-dev-report 6056317583).

    • Disclosure: the report and the PR body stay at class level. They state what the fix does, what the pins assert, and mechanism positions the card body already names. There is no step sequence and no per-case answer.
    • Form:
      • The PR is a draft against main. Its first line is Fixes #22168, with a line-start Clause-②: no (narrowing), the claim's line. The PR assignee is os-bill.
      • Three files: settings-service.ts, one pin file and one changeset. +377/−12.
      • No packages/spec path and the arm is no, so no contract review is owed. The PR is not governed.
      • It merges clean with main.
    • Diff, read by the seat:
    • The changeset grade moved from triage's patch to minor BREAKING, accepted. Clause-②: no (narrowing) is a declared-breaking change, and check-changeset-no-major's level axis requires minor or above for one, read clean with the PR body as its event. The ADR-0087 disposition is not-required.
    • Changeset, checked sentence by sentence: Reads, Refused now, Unchanged and "What changes for you". The last includes "None of the settings namespaces this repository ships declares a user-scoped key", which matches the round-1 census and its re-confirmation on 0e9371f0. Each matches the diff.
    • Evidence:
      • 65 pins over the engine-bound and in-memory stores. On base source, every negative pin is red and every control green.

      • Ablations:

        • M1 (presence check), 39 red;
        • M2 (owner comparison), 5 red: the over-answer pin is the one that holds it;
        • M12, 39 red;
        • M3 (refusal), 8 red.

        Each is restored to the HEAD blob, and no control was red in any leg.

      • The package suite: 39 files, 702 passed. Typecheck passes.

      • 64/64 derived gates, check:dual-build-cjs-loads included after its prerequisite build.

    • Carried, not filed (each lacks reach: no user-scoped key ships):
      • The no-user load still reads every owner's user rows. It is inert for the cascade now, and narrowing the load would be defence in depth.
      • Ownerless legacy rows stay unread and cannot be cleared through the service.
      • resetNamespace on a user-scoped key with an upper value writes null at the user rung and counts it cleared.
      • The "Writes fail loudly" table in content/docs/protocol/kernel/config-resolution.mdx has no row for the new refusal. The table is incomplete, not wrong. Carrier: the next edit of that page.
    • Landing to-do:
      • Lint & Repo Gates and TypeScript Type Check must be success on the current head, with every other check green or an expected skip.
      • Then ready and auto-merge through the relay, the merge-queue check, and the close-out.
  8. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T09:58Z. ⛔ Classes, positions and functions only.

    PR #22246 merged through the merge queue as 7518ee39. On origin/main, service-settings' settings-service.ts:

    • resolveKeyFromRows answers the user rung only for the caller who owns the row (callerUserIdOf);
    • setMany refuses a user-scoped key written with no user.

    The PR's Fixes line closed the card completed. This note also removes pm:dispatched and the assignee.

    • Grade and reach, as recorded: this was latent, with zero user-scoped keys in-tree (census 6053149206). The fix is defensive, ahead of the first manifest that declares scope: 'user'.
    • Carried, not filed: the no-user load still reads every owner's user rows, now inert for the cascade. Ownerless legacy rows stay unread. The "Writes fail loudly" table in config-resolution.mdx has no row for the new refusal. Each has no reach while no user-scoped key ships.
    • Still open with the maintainer: purging the edit history of the round-1 report (6053149206), which held the withheld procedure.
  9. added a commit that references this issue on Oct 9, 2026
    7518ee3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsdomain:servicespriority:p2Medium: important, M3security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions