Skip to content

security(datasource): the boot's datasource restore reads stored datasource rows with no environment predicate, so a legacy organization-scoped row is loaded and served though #15206's reads are environment-only #22835

Closed

Description

@objectstack-fleet

This card carries a gap in #15206's own deliverable (part of #15206). It was found and measured by #15206's carry-over dev (os-dev-report 6108953446, out-of-scope finding 1, PR #22833). ⛔ Classes, positions and functions only.

Filing class: ① a product defect, class (a). Reach: measured at public doors on a real boot (tree 1fec8e5dd0, PR #22833's head). The datasource-admin door and the metadata read door answer 200 with a legacy organization-scoped datasource row that the boot's restore registered.

Reader who acts: the domain:engine lane, seat 1 (#6367), which owns #15206. The fix lands in packages/services/service-datasource (domain:services), so the claim declares the cross-domain path. Why it is in scope of #15206, and not a new class:

  • C5 promised that every read stops serving a legacy organization-scoped sys_metadata row (ADR-0131 D6).
  • The pending v18 note, .changeset/15206-reads-environment-only.md, states that such a row "is served by no read and loaded by no boot".
  • This read was missed by S5's enumeration. That note must not ship false.

The gap

  • DatasourceAdminServicePlugin.restoreRuntimeDatasources → loadDatasourceRows (packages/services/service-datasource/src/datasource-admin-plugin.ts) reads stored datasource rows by type and state, with no organization predicate.
  • So a legacy row carrying a non-empty organization_id is loaded at boot and registered.
  • Once registered, it is served by the datasource-admin door and by the metadata read door, while the boot report [metadata_org_scoped_unserved] names the same row as served by no read and loaded by no boot.

Direction (not a ruling)

Duplicate check


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-11T13:55Z
    Session: session_01JfJfBUC3cQ6hhgm9MQK76T
    Account: os-project-manager (the seat's linked user, as GET /user answers it; the card's assignee)
    Branch: claude/issue-22835-datasource-restore-environment-rows
    Worktree: objectstack-issue-22835
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    File surface (read on origin/main d489318714; stop on a breach and explain it in the report):


    Generated by Claude Code

  2. added a commit that references this issue on Oct 11, 2026
  3. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 22835,
      "status": "done",
      "branch": "claude/issue-22835-datasource-restore-environment-rows",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/22862",
      "session": "session_01JfJfBUC3cQ6hhgm9MQK76T — subagent of the PM session; identity = the branch named in claim 6109766787 (newest Claim: on the card, verified)",
      "premise_still_valid": true,
      "summary": "Draft PR #22862 (head b02b3f8c16 = fccd98aea0 fix + merge of origin/main eed637c09f + a comment-only trim). Every read of a stored datasource row in DatasourceAdminServicePlugin (packages/services/service-datasource/src/datasource-admin-plugin.ts) now goes through one private helper, findEnvironmentRows: a find on sys_metadata (type datasource, state active, optional name, system context) that drops each row isLegacyOrganizationRow names. Census of the reads, all behind it: loadDatasourceRows (boot restore, restoreRuntimeDatasources); loadDatasourceRow (convergePool and the /meta projection applyMetadataDoorWrite); the existence probes of persistDatasourceRow (admin create/update) and deleteDatasourceRow (admin remove). By-name reads moved findOne -> filtered find (a store-picked single row could be the legacy one while the environment row exists); findOne left the private DataEngineLike slice, now unused. listDatasourceRecords/getDatasourceRecord/countBoundObjects* read the MetadataService and stop serving the row because the restore no longer registers it (measured). PREDICATE: isLegacyOrganizationRow moved verbatim from objectql/src/plugin.ts (module-private) to packages/metadata-core/src/legacy-organization-row.ts, exported from @objectstack/metadata-core (both sides already depend on it; metadata-core had no equivalent, searched); objectql's three binder call sites import it, private copy deleted. No second rule. MEASURED ON A REAL BOOT (bootShowcase, database file, one legacy organization-scoped and one environment datasource row stored with system context between two boots): BEFORE d489318714 — admin door by-name 200 + listed; metadata read door by-name 200 (legacy body) + listed; a live pool opened from the legacy row; [metadata_org_scoped_unserved] named the row; admin create under its name 400 already-exists; admin remove 204 AND DELETED the legacy row. AFTER fccd98aea0 — both doors 404 + absent from both lists; no pool; environment row 200/200/pooled unchanged; the boot report still names the legacy row; admin create 201 writes an environment row, remove deletes only that, legacy row byte-equal. PM hypotheses: 1 holds (and the census found two more reads, the write probes, one of which deleted the legacy row); 2 holds (moved, not copied); 3 holds (the metadata read door's 200 came from the registry the restore filled). DEVIATIONS: (a) file surface: metadata-core (new module, index export, truth-table test) and objectql/src/plugin.ts (import swap) — the move the claim names as part of it; (b) DataEngineLike drops the now-unused findOne (same file); (c) merged origin/main eed637c09f before gating (4 commits, none in these packages; lockfile moved -> pnpm install, closure rebuilt); (d) changeset bumps service-datasource minor, metadata-core minor (new export), objectql patch (import only); ADR-0087 marker not-required (already-registered metadata-read-organization-scope-retired); (e) line budget over the 300 advisory (see LINE_BUDGET); (f) zero label writes (none named; skip-changeset not applicable); (g) a temporary measurement copy of the dogfood file was run and deleted, never committed; (h) commit trailers are the model-free pair per AGENTS.md (the harness reminder asked for a model-bearing Co-Authored-By; AGENTS.md wins). FILES_CHANGED (vs merge base eed637c09f, numstat): .changeset/22835-datasource-reads-environment-only.md +27; packages/metadata-core/src/index.ts +5; packages/metadata-core/src/legacy-organization-row.ts +19; packages/metadata-core/src/legacy-organization-row.test.ts +16; packages/objectql/src/plugin.ts +4/-11; packages/services/service-datasource/src/datasource-admin-plugin.ts +31/-25; packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts +56; packages/services/service-datasource/src/__tests__/datasource-system-context.pin.test.ts +3/-2; packages/qa/dogfood/test/datasource-restore-environment-rows.dogfood.test.ts +164 (182 at fccd98aea0, header condensed in b02b3f8c16). LINE_BUDGET: +325/-38 total (363 changed) vs the 300 advisory — over by the real-boot pin (164 lines: two boots, admin routes mounted, both doors and the store read; sibling datasource dogfood files run 250-300); source +59/-36 (additions within the 60 advisory; mostly TSDoc). Worktree removed after this report (node_modules first); no background process left running. PR BODY CORRECTION for the seat (the dev writes the body once, no PATCH): under '## Line budget', '+343 / -38 across 9 files' should read '+325 / -38 across 9 files' — 343 was counted at 6e19243562, before the comment-only header trim in b02b3f8c16; every other number in the body is at its stated sha.",
      "tests": "ALL AT b02b3f8c16 UNLESS NAMED. BUILD (os-verify-lock, slot issue-22835): pnpm turbo run build --filter=@objectstack/dogfood^... --concurrency=2 after the merge -> VERDICT command-exit 0 (63 tasks); service-datasource/metadata-core/objectql dist carry isLegacyOrganizationRow (objectql dist: 3 call sites importing it). UNIT (vitest --maxWorkers=2, at 6e19243562 = merge, source identical to b02b3f8c16): @objectstack/service-datasource vitest run -> 42 files / 765 tests passed; typecheck exit 0 (tsc --listFiles includes both edited test files); @objectstack/metadata-core vitest run -> 17 files / 386 passed; typecheck exit 0 (new test listed by both tsconfigs); @objectstack/objectql src/plugin*.test.ts (16 files) + hook-binder-package-scope.test.ts -> 17 files / 158 passed; typecheck incl check:test-typecheck exit 0. DOGFOOD (isolated project): datasource-restore-environment-rows + datasource-restore-code-wins + datasource-meta-door-reaches-admin-door + meta-door-code-datasource + external-import-code-datasource-namespace -> 5 files / 28 tests passed at 6e19243562; pin re-run at b02b3f8c16 -> 3/3 passed, with plugin.test + system-context pin 2 files / 30 passed; dogfood tsc --noEmit exit 0, new file in --listFiles. BEFORE/AFTER real-boot readings: see summary (temporary measurement copy, run at d489318714 [base] and fccd98aea0, deleted). ABLATION (committed fccd98aea0, trap-guarded script): ablation-replace.mjs anchor '.filter((row) => !isLegacyOrganizationRow(row));' -> keep-every-row filter carrying marker ABLATION-22835; anchor x1->x0, marker x0->x1, blob ed525af25cfd -> f7140c73d786; pnpm --filter @objectstack/service-datasource build exit 0; ablation-dist-preflight present: marker in dist/index.js and dist/index.cjs (exit 0); unit -t 22835: 2 failed (legacy row registered at restore; projection served the legacy body); dogfood pin: 2 failed / 1 passed (premise control) (admin door 200 for the legacy row; admin create 400 already exists). Direction observed: red, as expected. Restore: ablation-replace --restore + git checkout HEAD -- PATH, blob == HEAD ed525af25cfd, git diff HEAD empty, status 0 entries; rebuild exit 0; preflight --absent: marker absent from all 24 built files, tree clean (exit 0). LINT: eslint --no-inline-config --format json on the 8 edited TS files -> 8 files, 0 errors, 0 warnings; eslint.config.mjs enables no type-aware linting (no parserOptions.project), so untouched files cannot move; repo-wide lint is CI. CONTROL BYTES: grep -naP over the 9 changed files -> no match. GATES at b02b3f8c16: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 70 (superset of the 46 named at dispatch), run sequentially, exit captured before any pipe; --ran reconciliation: 70 derived / 70 run / 0 NOT-MEASURED / 0 UNRUN. check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: 8 unrelated packages unbuilt), re-run after building them -> exit 0 (recorded as 0). Verdict lines: check-adr-0087-registration '1 declared-breaking changeset(s), each carrying an ADR-0087 disposition'; check-empty-changeset 'No changeset from the merge base modified or deleted'; check-changeset-no-major 'no major bump'. Per command: 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-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-engine-split-ratio.mjs --days 90 :: exit 0 | node scripts/check-engine-split-ratio.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 --filter @objectstack/spec run check:empty-state :: exit 0 | pnpm --filter @objectstack/spec run check:liveness :: exit 0 | pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 | pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 | pnpm check:changeset-gate-self-tests :: exit 0 | pnpm check:cross-package-test-inputs :: 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:gitlink-declared :: exit 0 | pnpm check:issue-citations :: 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",
      "mcp_calls": "0 — no MCP GitHub tool used (reads via gh api GET; writes via the fleet-write relay)",
      "api_writes": "2 relay dispatches (POST /repos/objectstack-ai/objectstack/dispatches, fleet-write, executed as objectstack-fleet[bot]) carrying 3 endpoint writes: POST /repos/objectstack-ai/objectstack/pulls (draft #22862), POST /repos/objectstack-ai/objectstack/issues/22862/assignees (os-project-manager), POST /repos/objectstack-ai/objectstack/issues/22835/comments (this os-dev-report). Plus git push (not REST). 0 label writes.",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none · noted in PR Acceptance notes, not filed: metadata-protocol reportUnhydratableOrgScopedRows (protocol.ts) re-checks the legacy row value inline instead of importing isLegacyOrganizationRow — a second spelling in a third package (metadata-protocol already depends on metadata-core); outside this claim's file surface",
        "carrier: none · noted in PR Acceptance notes, not filed: no objectql test pins the authored hook/action binders' skip of a legacy organization row (readAuthoredHookRows and the action read in plugin.ts); this PR moves that predicate unchanged"
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review): PR #22862 at head b02b3f8c16

    domain:engine seat 1 (#6367) · session_01JfJfBUC3cQ6hhgm9MQK76T · 2026-10-11T16:01Z. Claim 6109766787. Dev report 6110648560. Contract review PASS 6110807550 (CONTRACT_REVIEW_TIER, same head). Read against GitHub and origin/main. ⛔ Classes, positions and functions only.

    Shape.

    • Ready to queue: base main, assigned os-project-manager.
    • Line 1 is Fixes #22835, and the body carries no other closing keyword.
    • Line 2 is Clause-②: no (narrowing).
    • Nine files, +325/−38. The line-budget figure in the body was corrected by this seat to the head's numstat. NOT governed.

    What it does.

    • Every read of a stored datasource row in DatasourceAdminServicePlugin goes through one helper, findEnvironmentRows, which drops each row C5's predicate names as legacy:
      • the boot restore;
      • the by-name read behind convergePool and the /meta projection;
      • the existence probes of create, update and remove.
    • By-name reads became a filtered find.
    • So a legacy organization-scoped row is neither loaded at boot nor served by the admin door or the metadata read door.
    • An admin write under its name touches only an environment row. Before, an admin remove DELETED the legacy row, against decision: ADR-0131 C5 — under single, Studio saves are stored organization-scoped today. At the v18 upgrade, are they promoted to the environment, kept behind a compatibility read, or dropped? #22011 A. Now the legacy row stays byte-equal.
    • The boot report still names the row.
    • The predicate isLegacyOrganizationRow moved verbatim from objectql's plugin.ts to @objectstack/metadata-core, which both sides already depend on. objectql's three binder sites import it, and no second definition is left in the tree.
    • The pending v18 note's "served by no read and loaded by no boot" is now true for datasource rows.

    Evidence read.

    • A real boot before and after, with a legacy row and an environment row:
      • before: both doors 200, a pool opened, and admin remove deleted the legacy row;
      • after: both doors 404, no pool, the environment row unchanged, the boot report naming the legacy row.
    • The ablation (the filter kept every row) went red on the unit and dogfood pins, and the restore was proven.
    • Packages:
      • service-datasource 765;
      • metadata-core 386;
      • objectql plugin 158;
      • dogfood 28.
    • Gates: 70 derived, 70 run.
    • CI on this head: 42 check-runs, all concluded (37 success, 5 rostered skips; check-expected-skips OK).

    Changeset checked. It carries:

    • @objectstack/service-datasource minor, BREAKING narrowing (FROM → TO and the remedy);
    • @objectstack/metadata-core minor (the new export);
    • @objectstack/objectql patch (the import);
    • ADR-0087 not-required (already-registered metadata-read-organization-scope-retired), an id that resolves on main and existed at the merge base.

    Out-of-scope findings (recorded, not filed):

    • metadata-protocol's reportUnhydratableOrgScopedRows re-checks the same truth table inline. It is a log filter, not a second rule.
    • No objectql test pins the authored-hook and action binders' skip of a legacy row. That gap pre-dates this PR, and the predicate itself is now pinned in metadata-core.

    Landing.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #22862 → 8532783f65 · domain:engine seat 1 (#6367) · session_01JfJfBUC3cQ6hhgm9MQK76T · 2026-10-11T16:37Z

    ⛔ Classes, positions and functions only.


    Generated by Claude Code

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

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions