Skip to content

service-datasource: createDatasource / updateDatasource write sys_metadata without the save door, so they can mint or flip a datasource to managed under objects bound external (ADR-0015 §4.2) #25277

Closed

Description

@objectstack-fleet

Filing gate ③: a finding of the contract review of PR #25257 (record 6347940824, ③), first reported by its dev as a class-b finding with no carrier, and disposed in the seat's ACCEPT 6348040277 (item 5). It does not block that PR, which merged as bda53bb0b (card #25211).

  • Who acts: domain:services.
  • Dedupe: none found.

What happens on main (at bda53bb0b)

  • Since fix(metadata-protocol, spec)!: the save doors and composeStacks refuse an external binding on a named managed datasource #25257, the metadata-protocol save door refuses a datasource saved or changed to managed (including schemaMode unset) while stored objects bind it with external, naming the bound objects (422 INVALID_METADATA). The rule is one spec predicate, externalBindingDatasourceRefusal (packages/spec/src/data/external-binding-datasource.ts:90, exported).
  • The datasource-admin service does not go through that door. createDatasource (packages/services/service-datasource/src/datasource-admin-service.ts:608) and updateDatasource (:654) write through putDatasourceRecord (datasource-admin-plugin.ts:418), which calls metadata.register(...) and persistDatasourceRow (:152, a direct sys_metadata insert / update). saveMetaItem is never entered.
  • So the admin API (POST, and the PATCH that edits schemaMode) can make a datasource managed under objects already bound external — the pair the save door now refuses.

Wanted

  • createDatasource and updateDatasource judge the MERGED record against the objects bound to it with external, through the exported predicate (one copy of the rule), and refuse with the ADR-0112 envelope this service already uses, naming the bound objects. The service already has listObjects / countBoundObjects.
  • A federated datasource (schemaMode: 'external' or 'validate-only') and a datasource no object binds keep their answers.

Acceptance

  • Pinned: the admin POST and PATCH that would make a bound datasource managed are refused and nothing is stored or registered; the same calls with no external-bound object succeed.
  • Ablation: remove the judgement, the refusal pins go red, the controls stay green.
  • Clause-② / Metadata-protocol declared per AGENTS.md (an admin-API accept set narrows).

Activity

  1. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    and removed on Oct 22, 2026
  2. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: card thread · domain:services seat 1 (seat post #6021, 由Sales账户的项目负责 — project run by the Sales account) · p2 · the whole card · 2026-10-23T12:46Z
    Session: session_01AkGUa1szqpcew6SVTijZsv
    Account: os-sales (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-29nfa9 (the harness-pinned branch for this session; it names no issue, so the Duplicate Fix Guard's branch-name hint will not match — this claim is the identity record)
    Worktree: objectstack-issue-25277
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    File surface (read on origin/main f0fdd45b; stop on a breach and explain it in the report):

    • packages/services/service-datasource/src/datasource-admin-service.ts: createDatasource (:608) and updateDatasource (:654) judge the record they will persist against the objects bound to it with external, through the exported spec predicate externalBindingDatasourceRefusal (one copy of the rule; only its managed verdict counts, as the save door's datasource half reads it). Judged before writeSecret and before putDatasourceRecord, so a refusal stores, registers and mints nothing. Refused as the door's other record refusals are (400 DATASOURCE_ADMIN_ERROR), naming each bound object. On update the merged record is judged under the condition the record judgement already uses (a label/active-only write stays exempt, so a datasource can still be taken out of service). One new seam on DatasourceAdminServiceConfig lists the objects that bind a datasource.
    • packages/services/service-datasource/src/datasource-admin-plugin.ts: wires that seam to the engine's object registry (the objectql service's registry, the one the metadata save door writes through to, so an object saved or imported at runtime is seen), not the metadata service's boot-time copy.
    • Tests: a new pin file for the card's acceptance (POST and PATCH refused with nothing stored or registered; the same calls with no external-bound object, and a federated datasource, succeed), a plugin wiring pin, and the existing DatasourceAdminServiceConfig fakes in this package gain the new seam.
    • One changeset for @objectstack/service-datasource, declaring the break (**BREAKING** banner, the (narrowing) arm, an ADR-0087 disposition).
    • Not packages/spec (the predicate is used as exported), not packages/metadata-protocol, not the ADR, not the docs pages.
      Container & model: M, card thread (in-session executor), model: default tier (dispatch-gates.mjs --tier on this surface: "no path-derived mandate"), effort: high
      Clause-②: no (narrowing)
      Metadata-protocol: narrowed
      Basis for the Clause-② line: no because no published accept set or public surface widens. (narrowing) because the admin API's accept set narrows: a POST or PATCH that makes a datasource managed (or leaves schemaMode unset) under objects bound external was stored and is now refused. Metadata-protocol: narrowed follows the sibling door's declaration for the same pair (.changeset/25278-restore-doors-save-refusal-family.md): a value a metadata author could write through this door is refused. The full dogfood battery runs once on the final head.
      Responsibility: service-datasource (DatasourceAdminService.createDatasource / updateDatasource) produces the risk | the platform path that covers the pair is the metadata save door (externalBindingSaveRefusal in @objectstack/metadata-protocol), which this door never enters | reached by any caller entitled to POST / PATCH /api/v1/datasources (Setup → Datasources)
      Thread-read: none
      Serial constraints cleared: none — no open PR touches packages/services/service-datasource (PR search at claim time: only the standing Version Packages and shard-timings PRs) and no pm:dispatched card's claim names it.
  3. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 25277,
      "status": "done",
      "branch": "claude/project-thread-29nfa9",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25431",
      "session": "session_01AkGUa1szqpcew6SVTijZsv",
      "premise_still_valid": true,
      "summary": "createDatasource and updateDatasource now judge the record they will persist against the objects bound to it with external, through the exported spec predicate externalBindingDatasourceRefusal (only its managed verdict, as the save door's datasource half), before writeSecret and putDatasourceRecord; refused as 400 DATASOURCE_ADMIN_ERROR, one line per bound object. Update is judged under the existing record-judgement condition (label/active-only writes exempt). New REQUIRED DatasourceAdminServiceConfig.listObjectsBoundTo(datasource); the plugin answers it from the objectql service's object registry (the one the save door writes through to), not the metadata service's boot-time listObjects copy. Premise verified on origin/main f0fdd45bb: neither door entered saveMetaItem.",
      "tests": "On ab44e1bd: service-datasource suite (vitest run, under the lock) VERDICT command-exit 0, Test Files 56 passed (56), Tests 969 passed (969); typecheck VERDICT command-exit 0. New pins: datasource-admin-external-binding.test.ts (11 cases, real routes over real service) + 3 plugin wiring cases in datasource-admin-plugin.test.ts. Ablation via scripts/ablation-replace.mjs (anchor 'if (findings.length === 0) return undefined;' replaced by 'return undefined;', anchor 1 to 0, blob 1c8772e150d7 to c845ec0416b0, restored to HEAD blob, git diff HEAD empty): the two pin files 7 failed | 35 passed, the 7 red = exactly the refusal pins (5 door, 2 plugin), every control green. Tests import src (no dist hop), so no rebuild leg.",
      "gates": "dispatch-gates --commands on ab44e1bd: 68 families, each run, every one exit 0 (check:type-check-debt under the lock; build-reading families after the dogfood closure build). --ran: 68 derived, 68 run, 0 NOT-MEASURED (a DERIVED zero). eslint --no-inline-config on changed .ts exit 0; check-nul-bytes exit 0. PR check-runs at report time: Lint and Repo Gates success, TypeScript Type Check success, Build Core success, Governed Surface Queue Guard success, Check Changeset success, Test Core 1/6 3/6 5/6 6/6 success, Test Core 2/6 and 4/6 in_progress, every other run success or skipped.",
      "dogfood_battery": "ab44e1bd · os-verify-lock VERDICT command-exit 0 (1231s) · Test Files 290 passed, 3 skipped (293) · Tests 2494 passed, 18 skipped (2512) · no failures (PR comment on #25431)",
      "mcp_calls": "5 GitHub MCP calls, reads only: issue_read x2, search_pull_requests x1, search_issues x1, pull_request_read (get_check_runs) x1; plus claude-code-remote subscribe_pr_activity x1 (not a GitHub write). No MCP write tool.",
      "api_writes": "6 — all via scripts/pm fleet-write relay: label-write (assign os-sales, add pm:dispatched, remove pm:queue) on #25277; post-stamped claim comment on #25277; pr_create #25431 (draft, assignee os-sales); pr_request_reviewers #25431 (os-sales); post-stamped dogfood comment on #25431; post-stamped this report on #25277. Plus git push to the branch.",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed · countBoundObjects and countBoundObjectsDiagnosed (datasource-admin-plugin.ts:461, :486), the guard behind removeDatasource, count from metadata.listObjects(), the boot-time copy plugin.ts records as never reached by a runtime save; an object imported or saved at runtime is therefore not counted until restart. Read from code and that recorded reading; not reproduced at the public door, so Acceptance notes only. Dedupe words: removeDatasource bound count runtime import listObjects boot copy",
        "carrier: none · noted, not filed · content/docs/data-modeling/external-datasources.mdx (around line 97) names only PUT /api/v1/meta/datasource as refusing the managed-under-external pair; the admin API now refuses it too. Not falsified (the page is incomplete, not wrong); left to the docs lane. Flagged by the PR's Docs Drift Check."
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 (seat post #6021) · os-sales · session_016g7nYP9wdNx1GDa5XDr7Uy · 2026-10-23T14:21Z

    reading value
    PR #25431 MERGED through the queue (contract review 6366423384 PASS, ACCEPT 6366446473), head ab44e1bdb3
    origin/main carries b9c7a8f541

    State: the card was closed by Fixes #25277. pm:dispatched is off and the assignee is cleared. The card thread's slot is free.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions