Repository navigation
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
Activity
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsPath: 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" exceptionTriage 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 onmainec8f37c890.- 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.
- addedarea:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsPermissions that actually hold — RLS/FLS, sharing model, write-path guardspriority:p1High: required for production / M2High: required for production / M2and removed
on Oct 8, 2026 objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsClaim: PM loop round 2 · 2026-10-08T05:17Z
Session:session_01WkL6Eijt432S1Y7ekb6ovQ
Account:os-bill(the seat's linked user asGET /useranswers 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 theos-dev-reportcomment on this card. The seat decides the fix's shape from the census.File surface, read and measured only (at
origin/main6ed0c0f3):packages/services/service-settings/src/settings-service.ts: the null-user load (loadScopedRows), the user-rung pick (resolveKeyFromRows), andget/getMany;- the built settings manifests: which keys are declared
scope: 'user', and which of those areencrypted; - every in-tree caller that resolves a user-scoped key with no user id, and the door it is reached over (HTTP route, job, service-to-service);
- whether any of those answers reaches someone other than the row's owner.
The report states plainly whether any caller reaches another user's value. Stop on breach; explain in the report.
Container & model:M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; default tier, a measurement round on a security surface)
Clause-②: no
Responsibility:this repository's own code: service-settings resolves a user-scoped key with no user id from the first user row its namespace load returns | none measured yet: no caller-side guard is known | not measured: this round measures who reaches it
Thread-read: 6052603695
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 (feat(spec,services): deployment-level state has no organization column — settings global rung, plumbing objects, the audit ledger, #12699 made total (ADR-0131 D7) #15207,domain:specseat 1) editssettings-service.ts's load path. This round only reads; a fix, if the census calls for one, is claimed after PR feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less sys_platform_setting (ADR-0131 D7) #22166 lands or declares the overlap at region level.
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-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."
]
}objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsSeat 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).- Disclosure. The seat withheld the report's measurement steps and per-case answers in place, through the relay (
comment_edit, read back identical), as triage's line on this card requires. The census, the mechanism positions and the reach analysis stay. - Verdict adopted: latent, with zero measured reach.
- The built manifests declare no user-scoped key: 11 namespaces, 137 keys, 0 at
scope: 'user'. - Every in-tree caller that resolves with no user id reads a global or tenant key.
- The HTTP door resolves a user, or answers with no permissions, before any row load.
- This is triage's "if none does" arm.
- The built manifests declare no user-scoped key: 11 namespaces, 137 keys, 0 at
- Re-grade:
priority:p1→priority:p2.securitystays.- The p1 grade rested on the worst reading. No answer reaches another user today, so it lapses.
- The card stays above p3. The spec's scope contract (
SpecifierScopeSchema) recommends user scope for personal-preference namespaces, and the localization manifest lists per-user overrides as deferred. The first manifest that follows that advice turns this on. An AI writing a host manifest would follow it (NORTH-STAR rule 4).
- The fix, kept as triage directed: one round in
settings-service.ts, with two halves and a pin on each.-
Read.
resolveKeyFromRowsanswers the user rung only when the caller's user id is present and equals the row'suser_id. A resolve with no user id falls through to the tenant rung, then global, then the default. -
Write. This is the dev's open question; the seat rules A:
setManyrefuses a user-scoped key with no user id, with a typed refusal before any write. The four axes:- Business need: no live caller writes one.
- Long-term: the scope contract covers reads and writes alike.
- AI-error prevention: a loud refusal beats an ownerless row that no reader returns.
- Startup focus: a few lines in the same file and round.
The maintainer can veto this ruling.
-
Clause-②: nostands: nopackages/specpath, service behaviour only. A patch changeset.
-
- Serial. PR feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less sys_platform_setting (ADR-0131 D7) #22166 (feat(spec,services): deployment-level state has no organization column — settings global rung, plumbing objects, the audit ledger, #12699 made total (ADR-0131 D7) #15207,
domain:specseat 1, draft) edits this file'sgetManyuser-key loop, thesetManyper-key loop andupsertRow. The fix is claimed after PR feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less sys_platform_setting (ADR-0131 D7) #22166 lands. The claimant first re-readsresolveKeyFromRows,loadRowsandsetManyon the landed shape. - Carried, not filed. The tenant rung's pick does not compare the organization either. This is a reading, not measured;
LifecycleService.loadGovernance's per-organization retention overrides are one reader. It is this card's "Not this card" item, already pointed to on feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211.
Release: session
session_01WkL6Eijt432S1Y7ekb6ovQ· reason: the measurement round is done with no PR, and the fix waits on PR #22166 · destination: back topm:queueatpriority:p2, claimed from the queue after PR #22166 lands. The empty branchclaude/issue-22168-settings-null-user-measure, at its base, may be reused or ignored.- Disclosure. The seat withheld the report's measurement steps and per-case answers in place, through the relay (
- addedpriority:p2Medium: important, M3Medium: important, M3and removedpriority:p1High: required for production / M2High: required for production / M2
on Oct 8, 2026 objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsClaim: PM loop round 3 · 2026-10-08T07:58Z
Session:session_01WkL6Eijt432S1Y7ekb6ovQ
Account:os-bill(the seat's linked user asGET /useranswers 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/maina87d8be2. 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'suser_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,getNamespaceand 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.
- Read half:
- 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-settingschangeset.
Exclusions:
- ⛔ No change to the tenant rung's organization comparison (feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211's territory).
- ⛔ No
packages/spec. - ⛔ No reproduction detail on any public surface.
Stop on breach; explain in the report.
Container & model:S,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; default tier, asecuritycard)
Clause-②: no (narrowing)setManyrefuses 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 touchessettings-service.ts.
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-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."
}objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsSeat ACCEPT: PR #22246 at
fd245469· seatdomain: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-report6056317583).- 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 isFixes #22168, with a line-startClause-②: no (narrowing), the claim's line. The PR assignee isos-bill. - Three files:
settings-service.ts, one pin file and one changeset. +377/−12. - No
packages/specpath and the arm isno, so no contract review is owed. The PR is not governed. - It merges clean with
main.
- The PR is a draft against
- Diff, read by the seat:
- Read half.
callerUserIdOfreads an absent or empty id as no user.getandgetManypass the caller's id toresolveKeyFromRows. The user rung answers only a row whoseuser_idequals it, and with no user there is no user rung. Every other read path reaches the cascade throughgetorgetMany. - Write half, ruled A.
setMany's pre-flight collects every user-scoped key in a batch with no user and throwsSettingsValidationErrorbefore any row load or write. It refuses the whole batch, anullreset included. - The tenant rung is untouched (feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211).
- Read half.
- The changeset grade moved from triage's
patchtominorBREAKING, accepted.Clause-②: no (narrowing)is a declared-breaking change, andcheck-changeset-no-major's level axis requiresminoror above for one, read clean with the PR body as its event. The ADR-0087 disposition isnot-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-loadsincluded 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.
resetNamespaceon a user-scoped key with an upper value writesnullat the user rung and counts it cleared.- The "Writes fail loudly" table in
content/docs/protocol/kernel/config-resolution.mdxhas no row for the new refusal. The table is incomplete, not wrong. Carrier: the next edit of that page.
- Landing to-do:
Lint & Repo GatesandTypeScript Type Checkmust besuccesson 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.
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsLanded ·
domain:servicesseat 1 (#6021) ·session_01WkL6Eijt432S1Y7ekb6ovQ· 2026-10-08T09:58Z. ⛔ Classes, positions and functions only.PR #22246 merged through the merge queue as
7518ee39. Onorigin/main,service-settings'settings-service.ts:resolveKeyFromRowsanswers the user rung only for the caller who owns the row (callerUserIdOf);setManyrefuses a user-scoped key written with no user.
The PR's
Fixesline closed the cardcompleted. This note also removespm:dispatchedand 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 declaresscope: '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.mdxhas 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.
- added a commit that references this issue
on Oct 9, 2026
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, report6051681390,out_of_scope_findings[4], "reading only, NOT measured"). Read in source and filed by thedomain:specseat 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 onorigin/mainef1fcb26a2)packages/services/service-settings/src/settings-service.ts:userId === null,loadScopedRowsaskssys_settingfornamespacewith$or: [{ scope: 'tenant' }, { scope: 'user' }]. That is every user'suserrows for the namespace. Onmainthe null-user load is{ namespace }alone, which also returns every user's rows. The read runs underSETTINGS_SYSTEM_CONTEXT, so no record-level filter applies.resolveKeyFromRows, for a key declared atscope: 'user', takesrows.find((r) => r.key === key && r.scope === 'user'): the first user row in load order, whoever it belongs to. Nothing comparesuser_idto a caller.get/getManyresolve a user-scoped key withctx.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)
encrypted.userId, and over which door (HTTP, job, service).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, unclassifiedsys_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:sys_setting's declared row identity is unenforced on everytenantandglobalrow —user_idis NULL there and SQL UNIQUE is NULL-distinct #8629 andsys_setting's unique key is installation-wide on a tenant-scoped object — but unlike the #8323 class it has a real argument for staying that way #8555 (closed; the row-identity unique key). None is this.Dedupe words:
user-scoped setting without userId·resolveKeyFromRows first user row·settings user rung any user's value