Skip to content

service-datasource: importing an external table under a name that differs from its remoteName creates an object that answers 500 "no such table" — and the import does not survive a restart #21788

Description

@objectstack-fleet

QA-source: #21784 · integration-system.external-schema-browser-ui · acceptance[2]

Clause A3 of integration-system.external-schema-browser-ui (rev 1) fails in the 17.7 pre-release run #21784 (subject 316be321e). An independent verifier (RUNNER rule 7) reproduced it twice on a fresh DB: CONFIRMED, P2. Predates 17.6.0 (persistObject identical at 617f25f8a).

Reproduction

  1. Boot the showcase (it ships the showcase_external datasource with remote tables customers and orders).
  2. Admin POST /api/v1/datasources/showcase_external/external/tables/customers/import {"name":"ext_cust"} → 201 {object:{name:"ext_cust", external:{remoteName:"customers"}}}.
  3. GET /api/v1/data/ext_cust.
  • Expected: the remote customers rows.
  • Actual: 500 DATABASE_ERROR, log no such table: ext_cust. Importing with name == remoteName (orders) answers 200, which masks the defect.
  • After a restart on the same DB, both imported objects answer 404 OBJECT_NOT_FOUND: the import is not durable.
  • Control: PUT /api/v1/meta/object/{name} with datasource: "showcase_external" and external.remoteName: "customers" → 200, and GET /data/{name} serves the rows. The two doors that should share one metadata channel do not.

Mechanism

  • packages/services/service-datasource/src/plugin.ts:99-101 — persistObject only calls metadata.register('object', …). It never runs the engine's schema sync / external-object registration (packages/objectql/src/engine.ts, ~18353), so the SQL driver has no physical-table mapping and resolves the table by object name. The register is also not persisted to sys_metadata, so it is gone on restart.

Done when

The import twin persists through the same save path as PUT /meta/object (durable, schema-synced, remoteName honoured), and a dogfood test imports under a different name, reads rows, restarts and reads them again.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: an API a customer can call — external data used as its own objects | integration-system.external-schema-browser-ui | P2

    Triage: first grade — bug · priority:p2 · domain:services · area:api · pm:queue. The import twin saves through the same path as PUT /meta/object

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

    Triage: lands in packages/services/service-datasource/src/plugin.ts (persistObject) ⇒ domain:services; rationale: the metadata door already does the durable, schema-synced save, and the import twin skips it.


    Generated by Claude Code

  2. added
    area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobs
    bugSomething isn't working
    and removed on Oct 5, 2026
  3. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-05T06:09Z
    Session: session_011K3zqE8Pv1Evw5hc8tZCnN
    Account: os-steve (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-21788-external-import-saves-like-meta
    Worktree: objectstack-issue-21788
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    File surface (at origin/main 9059082d), per triage's direction 5987315042:

    • packages/services/service-datasource/src/plugin.ts: persistObject (about :99–:101) goes through the save path PUT /meta/object uses. The result is persisted, schema-synced, and honours remoteName. Plus service-datasource tests.
    • The card's dogfood pin: import under a name that differs from the remote table, read rows, restart on the same DB, and read them again. It also checks that the same-name import keeps working. The pin goes in a NEW file under packages/qa/dogfood/test/, or extends the existing showcase-external-autoconnect.dogfood.test.ts; both are declared cross-lane on [PM seat] domain:cli — ⏳ vacant #6024 in this act.
    • content/docs/** sentences this makes false, and a patch changeset.

    ⛔ No second registration path beside the metadata door's save (triage). ⛔ No change to the import route's request or response shape. ⛔ No packages/spec edit. If the metadata door's save path lives in an engine-lane package and cannot be reached as a service, stop and report before editing it.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier).
    Clause-②: no


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 21788,
      "status": "blocked",
      "branch": "claude/issue-21788-external-import-saves-like-meta",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/21837",
      "session": "session_011K3zqE8Pv1Evw5hc8tZCnN — subagent run, so this is the parent PM session's id (container CLAUDE_CODE_REMOTE_SESSION_ID cse_011K3zqE8Pv1Evw5hc8tZCnN)",
      "premise_still_valid": true,
      "summary": "The fix is done and open as draft PR #21837. persistObject in service-datasource/src/plugin.ts now calls saveMetaItem({ type: 'object', name, item }) on the 'protocol' service. That is the call PUT /meta/object makes for an object: no organizationId, because object is not org-overridable. The save door is resolved when the import runs, through a getter, not at init(). No second registration path, no route shape change, no spec edit, no new export. H1 measured in both halves on the real `pnpm dev` showcase at base 3237b4a2: import ext_cust gives 201 and then GET /data/ext_cust gives 500 DATABASE_ERROR (log 'no such table: ext_cust'); sys_metadata holds no row for either import; after a restart both give 404 OBJECT_NOT_FOUND; the PUT /meta/object control is durable. H2 and H3 measured after the fix: both imports serve rows, both have sys_metadata rows, and both serve rows again after a restart. Blocked on two things only the seat can settle. (1) The card's dogfood pin is written and measured but not committed, because committing it needs edits to existing packages/qa files that the dispatch puts on its stop list. (2) Making imports durable lets an import reach a boot abort that already exists for door-saved federated objects: Gate 2 reports the injected anchors as missing remote columns, and the default onMismatch 'fail' aborts the next boot. I measured a remedy but did not apply it here.",
      "tests": "All measured at HEAD 9f5279c8 (branch after merging origin/main 088428fb), unless a line says otherwise. (a) pnpm --filter @objectstack/service-datasource exec vitest run --maxWorkers=2: 'Test Files 36 passed (36) / Tests 713 passed (713)'. pnpm --filter @objectstack/service-datasource typecheck: 'tsc --noEmit' exit 0, and its program includes the new test file (tsc --listFiles count 1). (b) New pin src/__tests__/external-import-saves-through-metadata-door.test.ts: 4 passed. (c) Ablation, run with the fix committed (2beb1441), via scripts/ablation-replace.mjs in WRAP mode. Anchor \"await door.saveMetaItem({ type: 'object', name, item: definition });\" went x1 to x0; it was replaced by a lazily-resolved metadata.register('object', ...) with marker ablated21788; blob d1d6a114 went to 475e114c. Rebuilt service-datasource. ablation-dist-preflight found the marker in dist/index.js and dist/index.cjs. Unit result: 'Tests 3 failed | 1 passed (4)', AssertionError 'expected vi.fn() to be called 1 times, but got 0 times'; the no-save-door case stays green by construction. Dogfood pin (run locally, see deviations): 'Tests 3 failed | 1 passed (4)', with '404 OBJECT_NOT_FOUND' on the renamed read, on the same-name read and after the restart. Restore leg: blob after restore d1d6a114 equals HEAD and git diff HEAD is empty. Rebuilt; preflight --absent reported 'marker absent from all 24 built files' and 'working tree clean against HEAD'. Unit 4/4 and dogfood 4/4 green again. The ablation's expected direction was red, and red is what it showed. Dogfood case 3 (GET /meta/object read-back) stayed green under the ablation, so it does not discriminate, and it is dropped from the proposed version. (d) Real composition (`pnpm dev -- -p 38917`, measured at 2beb1441): before and after table in the PR body. (e) Gates: 63 of 63 dispatch-gates commands exit 0, and dispatch-gates --ran gives '63 derived, 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code)'. (f) Narrowed lint: eslint --no-inline-config --format json on the 2 touched .ts files returned 2 files, 0 errors, 0 warnings. The eslint config sets no parserOptions.project, so linting is not type-aware and this diff cannot move the verdict on any untouched file. Full `pnpm lint` is left to CI. (g) CI on PR #21837: in_progress or not yet read; not waited on.",
      "mcp_calls": "0",
      "api_writes": "3 — all through the fleet relay (repository_dispatch, executed as objectstack-fleet[bot]): pr_create POST /repos/objectstack-ai/objectstack/pulls (draft, run 37278397114, 7002 of 7002 bytes read back identical); label-write assign POST /repos/objectstack-ai/objectstack/issues/21837/assignees os-steve (run 37278447654, read back matches); post-stamped comment POST /repos/objectstack-ai/objectstack/issues/21788/comments (this report). Not REST writes: git push of 4 pushes to the claim branch. Reads only: gh api GET of issue 21788 and its comments.",
      "open_questions": [
        {
          "question": "The card's dogfood pin needs @objectstack/service-datasource importable from packages/qa/dogfood. The verify harness does not mount ExternalDatasourceServicePlugin. Committing the pin needs a dependency line in packages/qa/dogfood/package.json (plus its pnpm-lock importer line). It also needs a source alias in packages/qa/dogfood/vitest.config.ts, because check:test-source-alias's KNOWN_UNALIASED_TEST_IMPORTS is shrink-only. The dispatch's stop list bars edits to existing packages/qa files. The pin is written (scratchpad issue-21788/external-import-saves-like-meta.dogfood.test.ts.proposed, 3 cases) and measured: green on the fix, red under the ablation.",
          "options": [
            "A: authorize the three edits (dogfood package.json dependency, lockfile importer line, vitest.config.ts source alias) in a patch round on this PR, and commit the pin as is",
            "B: wire ExternalDatasourceServicePlugin into packages/verify's bootStack when an app declares datasources, mirroring `objectstack dev`/serve (a composition change for every showcase dogfood boot), then commit the pin with no qa manifest edit",
            "C: land without the dogfood pin and change the PR's first line to 'Part of #21788'"
          ],
          "recommendation": "A. Fact axis: the card's acceptance names this pin, and only a booted stack proves the restart half; the unit pins cannot. Long-term axis: a declared dependency plus a source alias is the repo's sanctioned shape, while B changes every showcase boot's composition to serve one file. AI-error axis: A keeps the subject resolved to source, so a stale dist cannot green it. Startup-scope axis: A adds three lines and no new surface."
        },
        {
          "question": "With imports now durable, a federated object imported on a datasource with the default external.validation.onMismatch: 'fail' makes the next boot abort. Measured: \"Object 'ef_cust' does not match its remote table on datasource 'ext_fail'\" with missing_column for organization_id, created_by, updated_by, owner_id and owning_business_unit_id. This was already true on main for an object saved through PUT /meta/object; ef_meta was measured the same way. Cause: Gate 2 (ExternalValidationPlugin, then validateObject) reads the stored object with the platform-injected anchors and compares them to the remote. Code-defined federated objects are read raw and pass. Before this PR an import vanished at restart, so it never reached the abort. Measured remedy, not in the PR diff: validateObject skips unprovisionedInjectedColumns(obj) from @objectstack/spec/data, the #7865 marker for exactly these anchors. With it, the aborting database booted, and both datasources validated ok: true.",
          "options": [
            "A: authorize that bounded fix in this PR: external-datasource-service.ts validateObjectUsing (about 3 lines) plus a pin that a stored federated object carrying injected anchors validates ok",
            "B: file it as its own card (class a, door-reachable today) and hold #21837 behind it with a Blocked-by line",
            "C: land #21837 alone and accept the abort for default-policy datasources until the card lands"
          ],
          "recommendation": "A or B, never C. Fact axis: the seat's import is the common door, and C turns 'lost on restart' into 'server refuses to boot'. Long-term axis: the fix uses the spec's own provenance predicate, which the #7865 ruling exists for, so no consumer-side alias is needed. AI-error axis: a false 'missing_column' at error severity teaches operators to set onMismatch: 'warn' and so hide real drift. Startup-scope axis: about 3 lines in a file already in this lane. I prefer A for its single landing. B is the stricter reading of the in-place-fix rule, since the defect class differs from this card's."
        },
        {
          "question": "Clause-②: the claim says 'no'. Measured: an import the metadata door refuses now answers 400. For example, a re-import with excludeColumns that drops a column used to answer 201 (an in-memory overwrite) and now gets 409 DESTRUCTIVE_CHANGE, relayed as 400 EXTERNAL_IMPORT_ERROR. Strictly, the import route's accepted set narrows for inputs whose earlier 201 was never durable.",
          "options": [
            "A: keep 'Clause-②: no' (patch). The refused inputs never produced a durable or working object, and this is the door's existing contract applied to its twin",
            "B: 'Clause-②: yes (narrowing)', which marks the changeset BREAKING"
          ],
          "recommendation": "A. Fact axis: no caller got a durable object from those 201s. Long-term axis: one contract for two doors is the card's point. AI-error axis: refusing loudly beats a 201 that is lost. Startup-scope axis: a breaking marker on a bug fix would mis-signal. I am raising it, not changing it, because the line is the seat's."
        }
      ],
      "out_of_scope_findings": [
        "class: a · reach: public door + wrong answer — PUT /api/v1/meta/object/NAME binding a federated object (datasource with default external.validation.onMismatch 'fail', created at runtime via POST /api/v1/datasources) answers 200 and serves rows; the next boot ABORTS with \"Object 'ef_meta' does not match its remote table on datasource 'ext_fail'\" listing missing_column organization_id, created_by, updated_by, owner_id, owning_business_unit_id; POST /external/validate on a 'warn' datasource after restart answers ok:false with error-severity missing_column diffs for every stored (door-saved or imported) federated object while code-defined ones pass · evidence: serve5.log / serve7.log in scratchpad issue-21788; validateObject compares obj.fields (the injected-anchor view) and skips only BUILTIN_COLUMNS id/created_at/updated_at; remedy measured (skip unprovisionedInjectedColumns) — see open_questions[1] · dedupe words: external validation, missing_column organization_id, onMismatch fail boot abort, Gate 2 injected system fields, federated object stored",
        "class: a · reach: public door + wrong answer (reachable only with this PR) — POST /api/v1/datasources/showcase_external/external/tables/customers/import {\"name\":\"ext_cust\",\"excludeColumns\":[\"email\"]} after a first import answers 400 EXTERNAL_IMPORT_ERROR \"object/ext_cust would drop or transform existing data: Field 'email' removed … — re-submit with ?force=true to proceed.\"; the import route reads no force (and refuses no unknown query param), so the prescription loops · evidence: destructiveChangeRemedy (metadata-protocol) has faces only for package-duplicate and meta-dispatch; default text names ?force · dedupe words: destructive change remedy force import, DESTRUCTIVE_CHANGE external import, re-import excludeColumns, write face",
        "class: a · reach: public door + wrong answer — POST /api/v1/datasources/showcase_external/external/validate right after PUT /api/v1/meta/object/ext_cust_meta (federated on that datasource) lists only the code-defined objects; the door-saved object appears only after a restart (and now the same holds for imports) · evidence: serve3-probe-fixed.txt (results: showcase_ext_customer, showcase_ext_order only) vs serve4-validate.txt after restart (ext_cust, orders, ext_cust_meta present); the federation service reads objects from the 'metadata' service, which loads sys_metadata objects at boot only · dedupe words: external validate missing object, validateDatasource runtime object, metadata service sys_metadata until restart",
        "carrier: whoever takes persistCatalog (H5) — measured under the verify harness only: ExternalDatasourceServicePlugin as an extra plugin finds no 'metadata' service at init(), so persistCatalog is never wired there (the import in the base-tree harness run was refused 'requires a writable metadata store' for the same reason); same record-at-init shape this PR removes from persistObject; persistCatalog remains register-only (in-memory + writable datasource: loaders), its durability on `pnpm dev` NOT MEASURED · noted, not filed (承接者:无 if nobody takes it)",
        "carrier: PR #21837 Acceptance notes — an imported object's sys_metadata_history row records recorded_by null where the door records the caller's user id (measured: ext_cust null vs ext_cust_meta user id); the import route does not hand its caller to IExternalDatasourceService.importObject and that spec contract has no actor parameter · noted, not filed"
      ],
      "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack at 9f5279c8 derived 63 commands; every one run with its exit code captured before any pipe. All 63 :: exit 0: check-adr-0087-registration (--base, --self-test), check-changeset-no-major (--base, --self-test), check-ci-filter-parity, check-closing-keyword-parity (+ --self-test), check-comment-mask-adoption (+ --self-test), check-comment-mask-corpus, check-dts-emitted --self-test, check-empty-changeset (--base, --self-test), check-issue-citations, check-keyed-text-bounds (+ --self-test), check-platform-object-tenancy-census (+ --self-test), check-plugin-teardown-shape (+ --self-test), check-registry-log-declared (+ --self-test), check-rest-log-spy-declared (+ --self-test), check-system-context-census (+ --self-test), check-tenant-audit-census (+ --self-test), check-undeclared-dep-imports (+ --self-test), docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/release-rehearsal-clone --self-test, release-pending-publish --self-test, spec check:duration-unit-keys, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:doc-authoring, check:driver-memory-census, check:dts-closure, check:dual-build-cjs-loads, check:engine-double-contract, check:gitlink-declared, check:issue-citations, check:lean-entry-closure, check:logger-receiver-detach, check:nul-bytes, check:objectql-double-limit, check:objectui-changeset, check:org-identifier, check:page-declaration-shape, check:pm-changeset-deadline-census, check:published-files, check:query-options-erasure, check:refd-timer-probe, check:slot-lookup, check:sourcemap-no-sources-content, check:test-source-alias, check:tier-file-adoption, check:type-check-coverage, check:type-check-debt, check:watch-hint-literal, check:where-matcher. --ran verdict: '63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — all 63 recorded an exit code and none of them is 3)'. The dispatch-time list (49 commands, for plugin.ts only) is a subset; the 14 added families come from the changeset path and the new test path, and all of them were run. Before the merge, the derivation flagged a STALE TREE (check-route-envelope.mjs and engine-double-contract.pinned.json changed upstream); the branch was merged with origin/main and the list re-derived, giving the same 63.",
      "line_budget": "n/a",
      "deviations": [
        "The dogfood pin is not committed (stop list: no edits to existing packages/qa files). To measure it, an UNTRACKED symlink packages/qa/dogfood/node_modules/@objectstack/service-datasource pointing at the workspace package stood in for the missing dependency line. It was removed afterwards.",
        "To keep ablation-dist-preflight's whole-tree check clean, the dogfood file sat in a LOCAL-ONLY commit (d64dcf7f) during the ablation. That commit was reset with git reset --soft HEAD~1 and never pushed. The remote branch head equals the pushed history: 2beb1441, then 0bc13c54, then merge 9f5279c8.",
        "The gate derivation flagged a stale tree, so origin/main 088428fb was merged into the branch (merge 9f5279c8). The merge commit message was amended before its first push to carry the model-free trailer pair. Build state was refreshed after the merge (pnpm install --frozen-lockfile; turbo build of the service-datasource closure: 18 tasks, 17 cached).",
        "Throwaway experiment, never committed: the Gate 2 remedy (skip unprovisionedInjectedColumns) was applied to external-datasource-service.ts to measure it. The file was restored with git checkout HEAD (blob 2f1730e3 equals HEAD), dist was rebuilt, and ablation-dist-preflight --absent reported the marker absent and the tree clean.",
        "For the restart measurements, real-composition runs used `pnpm dev -- -p 38917` WITHOUT --fresh, against the worktree's own examples/app-showcase/.objectstack. Every server was stopped by its recorded PID tree; port 38917 ended with 0 listeners, and the DB directory was deleted before the worktree was removed. A runtime datasource ext_fail (default policy) was created on that throwaway DB to measure the boot abort, and one stored row was deleted by hand there to unblock the boot.",
        "Lane commitment ('every refusal case asserts code and status'): unit case 4 (no save door) asserts the refusal's message and that nothing was introspected or saved. It cannot assert code and status: that is the service's pre-existing refusal, a plain Error with neither. The route stamps 400 EXTERNAL_IMPORT_ERROR on every refused import. The other refusal case asserts code INVALID_METADATA and status 422.",
        "One lock call ended exit 99 (queue-timeout, another seat's dogfood closure build held the lock). It re-acquired under the same OS_VERIFY_LOCK_SLOT and was not re-queued at the back.",
        "Transcript hygiene: an env listing printed the first 80 characters of OS_FLEET_PRIVATE_KEY into this run's local transcript: the base64 PEM header and the ASN.1 length prefix, no key material past that. It reached nothing on GitHub. Reported so the seat can judge whether rotation is warranted.",
        "PR body footer: written in the AGENTS.md session-URL form, as the dispatch asked, not the harness reminder's alternative footer."
      ],
      "files_changed": [
        "packages/services/service-datasource/src/plugin.ts",
        "packages/services/service-datasource/src/__tests__/external-import-saves-through-metadata-door.test.ts (new)",
        ".changeset/21788-external-import-saves-like-meta.md (new, patch, @objectstack/service-datasource)"
      ]
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Review: the dev's report 5990164407 · PR #21837 at 9f5279c8 · seat domain:services#1 · session_011K3zqE8Pv1Evw5hc8tZCnN · 2026-10-05T07:42Z

    The fix is accepted as built: persistObject saves through the metadata door's own saveMetaItem, resolved when the import runs. Measured on the real showcase: both imports serve rows, both have sys_metadata rows, and both survive a restart. The ablation (register-only put back) turned the unit pins and the dogfood pin red, and the restore was proven. All three questions are answered here.

    Q1 (the dogfood pin) → A, surface revision 1. The dev may edit three existing files, so the card's own acceptance pin lands:

    • the @objectstack/service-datasource dependency line in packages/qa/dogfood/package.json;
    • its lockfile importer entry, regenerated with pnpm install and never by hand;
    • the source alias in packages/qa/dogfood/vitest.config.ts (the repo's check:test-source-alias shape).
      The qa-lane amendment is posted on [PM seat] domain:cli — ⏳ vacant #6024 in this act. Not B: it would change the composition of every showcase dogfood boot to serve one file. Not C: the restart half is only provable at the door.

    Q2 (the boot abort) → A, surface revision 2.

    Q3 (the declaration) → corrected in this act (one round: the claim, the PR line and the changeset move together).

    • Some re-imports that answered 201 now answer 400: a re-import that would destructively change an existing object, or a name that collides with an existing object. That narrows the import route's accepted inputs, whether or not the earlier 201 was durable.

    Clause-②: no (narrowing)

    • The declaration is BREAKING: a minor changeset with the ! banner and the handling for an affected caller.
    • No authorable metadata key, export or config field moves, so no FROM → TO migration line is owed. The ADR-0087 disposition is read off the gate's own output. A migration-ledger entry is not expected; if the gate leaves registered as the only honest disposition, stop and report rather than add one.

    Findings:


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 21788,
      "status": "done",
      "round": "patch round 1 (seat verdict 5990234116; qa-lane amendment 5990241427)",
      "branch": "claude/issue-21788-external-import-saves-like-meta",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/21837",
      "head": "8d640fa2",
      "session": "session_011K3zqE8Pv1Evw5hc8tZCnN — subagent run, so this is the parent PM session's id",
      "premise_still_valid": true,
      "summary": "All three rulings are built on the same claim 5989078646, branch and PR. Q1 → A: the card's dogfood pin (3 cases; the non-discriminating meta read-back case dropped) is committed. It comes with the @objectstack/service-datasource dependency line in packages/qa/dogfood/package.json, its lockfile importer entry regenerated by pnpm install, and a source alias in packages/qa/dogfood/vitest.config.ts. No other qa file changed. Q2 → A: validateObjectUsing now skips the columns unprovisionedInjectedColumns(obj) names. It reuses that predicate and builds no second list. This also closes the same boot abort for objects saved through PUT /meta/object. Q3: the changeset is minor with a `fix(service-datasource)!:` title, a BREAKING banner, `Clause-②: no (narrowing)`, one measured handling line, and the ADR-0087 marker `not-required (no-migration-prescription)`, which the gate accepted. The PR body's line-start declaration should read exactly `Clause-②: no (narrowing)`; the seat writes the body. Neither #21841 nor #21842 is touched. Re-measured on `pnpm dev`: a default-policy datasource holding an imported object and a door-saved object boots and validates ok, and real drift still aborts the boot.",
      "tests": "At HEAD 8d640fa2 (branch after merging origin/main 8832655a). service-datasource: 'Test Files 37 passed (37) / Tests 717 passed (717)', typecheck exit 0. New external-validate-injected-anchors.test.ts: 4 pass (premise case: the fixture carries the 5 anchors; ok on validateObject and validateDatasource; NEGATIVE CONTROL: a declared loyalty_tier the remote lacks gives [{kind:'missing_column', column:'loyalty_tier', severity:'error'}] as the only diff and ok:false; an author-declared owner_id is still compared). Dogfood pin: 'Tests 3 passed (3)'. @objectstack/dogfood typecheck: exit 0. Ablation 1 (scripts/ablation-replace.mjs, WRAP mode; persistObject put back to a lazily-resolved metadata.register): anchor x1 to x0, blob d1d6a114 to 475e114c. Dogfood pin 'Tests 3 failed (3)' with 404 OBJECT_NOT_FOUND on the renamed read, the same-name read and after the restart. No rebuild was needed, because the new alias resolves the subject to src; the red run is the proof. Restore: blob d1d6a114 equals HEAD, git diff HEAD empty, pin 3/3 green. Ablation 2 (skip clause replaced by an always-false test): anchor x1 to x0, blob 4e7b236e to d17609d8. Unit 'Tests 3 failed | 1 passed (4)': the ok case fails (ok:false), and the two negative controls fail too, because they assert exact equality on diffs and the anchors come back beside the real column (6 and 5 diffs). Only the fixture-premise case stays green. Restore: blob 4e7b236e equals HEAD, git status clean, 4/4 green. `pnpm dev` re-measure, run at 8d640fa2's tree on a fresh DB: runtime datasource ext_fail (default onMismatch 'fail') with ef_cust imported and ef_meta saved through PUT /meta/object; restart, boot ok; POST /datasources/ext_fail/external/validate gives ok:true for ef_cust, ef_meta and ef_cust_v2. Negative control: PUT /meta/object ef_drift declaring loyalty_tier; restart, boot ABORTS with \"Object 'ef_drift' does not match its remote table on datasource 'ext_fail'\" and 'missing_column: customers.loyalty_tier' only. Handling-line remedies, measured: a destructive re-import gives 400; the import with ?force=true gives the same 400 (#21841's subject); import under a new name gives 201 and rows; PUT /meta/object/ef_cust?force=true gives 200 (409 without force); an import named after an existing object with different fields (showcase_ext_customer, ef_meta) gives 400 destructive change. Narrowed eslint --no-inline-config --format json over the 6 changed .ts files: 6 files, 0 errors, 0 warnings. The config sets no parserOptions.project, so linting is not type-aware and untouched files' verdicts cannot move. CI on PR #21837: not read, not waited on.",
      "mcp_calls": "0",
      "api_writes": "1 this round — post-stamped os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/21788/comments via the fleet relay. Not REST: git push of 3 pushes to the claim branch (99b5420d+f2e3d7ff with merge ac09f612, then 86c01b91+b9690f18, then 8d640fa2). Reads only: gh api GET of comments 5990234116 and 5990241427 and issues 21841 and 21842.",
      "adr_0087": {
        "marker": "adr-0087: not-required (no-migration-prescription), carried in the changeset as the comment-form marker line the gate reads; reason text: a runtime refusal on one REST route (the import relays the metadata door's own save refusals as 400 where it answered 201 with an in-memory write no restart kept); no authorable key, spelling, export, config field or stored shape moves",
        "deciding_output_without_marker": "✗ check-adr-0087-registration: 1 problem(s). .changeset/21788-external-import-saves-like-meta.md declares a breaking change (BREAKING, bang, clause-②-narrowing) but no `adr-0087:` disposition marker. Add ONE of these lines: registered SOME-MIGRATION-ID | not-required (unpublished) | not-required (already-registered SOME-MIGRATION-ID) | not-required (no-migration-prescription) | not-required (runtime-interface-only path#Symbol) | not-required (type-surface-only path#Symbol)",
        "deciding_output_with_marker": "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. .changeset/21788-external-import-saves-like-meta.md [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)",
        "why_not_the_others": "unpublished: @objectstack/service-datasource publishes. already-registered: no ADR-0087 id covers this route. runtime-interface-only and type-surface-only: no TS declaration changes. registered: no authorable key, spelling, export or stored shape moves, so there is nothing for `objectstack migrate meta` to rewrite. The gate's prescription detector found no migration prescription in the body (it carries one handling line, with no FROM → TO), so no-migration-prescription held. 'registered' was not the only honest disposition, so no ledger entry was added."
      },
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: the seat (#21841's build) — measured while checking the handling line: importing customers under name showcase_ext_customer (a packaged code-defined object) was refused only because the field types differ (currency vs number). An import under a packaged object's name whose fields do NOT conflict would save an env-wide overlay of that packaged object, exactly as PUT /meta/object of the same body does. Not measured on a compatible body. Same door semantics, so noted, not filed",
        "carrier: PR #21837 Acceptance notes (seat to append) — persistCatalog stays register-only and decided at init(); never wired under the verify harness; durability on `pnpm dev` NOT MEASURED · noted, not filed",
        "carrier: PR #21837 Acceptance notes (seat to append) — imported object's sys_metadata_history row records recorded_by null (measured: ext_cust null vs ext_cust_meta's user id); the import route does not pass its caller and IExternalDatasourceService.importObject has no actor parameter · noted, not filed"
      ],
      "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack (no paths) at 8d640fa2 derived 78 commands over 9 changed paths. All 78 :: exit 0, each exit code captured before any pipe. --ran gives '78 derived, 78 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — all 78 recorded an exit code and none of them is 3)'. The 15 families new since round 0, because the diff now touches pnpm-lock.yaml and the dogfood package: check-changeset-fixed, check-osv-exemptions (+ --self-test), spec check:empty-state / check:liveness / check:llms-txt / check:strictness-ledger / check:variant-docs, check:dispatcher-error-vocabulary, check:manifest-repository-directory, check:merge-driver, check:override-consistency, check:turbo-task-graph, check:vendor-export-contract-resolve, check:workspace-manifest-cycles. All exit 0. Named separately: `node scripts/check-adr-0087-registration.mjs --base origin/main` exit 0 ('1 declared-breaking changeset(s), each carrying an ADR-0087 disposition'); `node scripts/check-changeset-no-major.mjs --base origin/main` exit 0 ('This diff introduces no `major` bump'; its clause-② level axis reads NOT APPLICABLE locally because there is no pull_request payload, so CI reads the PR body's line). Also: check:test-source-alias exit 0 ('60 registered as still resolving a workspace dep through dist/'), so the new dogfood import is aliased and the shrink-only ledger is unchanged. The derivation did not flag a stale tree; origin/main is 2 commits past the merge base, and they were not merged.",
      "line_budget": "n/a",
      "deviations": [
        "The lockfile regeneration (`pnpm install`, never hand-edited) also added `deprecated: yuku-analyzer runs on yuku-core since 0.14` to 12 @yuku-analyzer/binding-* package entries. That is current registry metadata pnpm wrote in the same run, beside the one importer entry. It was not reverted, because reverting it would be a hand edit.",
        "Pin (b)'s second half ('a default-policy datasource still aborts on that real drift') is MEASURED end to end on `pnpm dev`; it is not a committed end-to-end pin. The committed half is the service-level negative control: the real drift is a missing_column at error severity, ok:false, the only diff, and that is the input Gate 2's fail branch throws on. The runtime's existing external-validation-plugin.test.ts already pins that fail throws on a measured diff. A committed booted pin would need ExternalValidationPlugin from @objectstack/runtime, which dogfood does not declare, and the harness's validate path sees no objects (the init-time verdict noted for persistCatalog). Both are outside the authorized surface.",
        "Ablation 2's two negative controls also went red, because they assert exact equality on the diff list and the anchors come back. Their purpose (real drift stays loud) is carried by the green run, and the `pnpm dev` drift abort measures it end to end.",
        "Lock: three exit-99 queue timeouts this round (holders were other seats' spec suite and verify scripts, alive and CPU-active). The slot was re-requested under the same OS_VERIFY_LOCK_SLOT each time, and it resumed its place. My first gate run was stopped by its own PID before the ablation so it could not read a mutated tree, and it was re-run in full afterwards.",
        "The merge commit ac09f612 message was amended before its first push to carry the model-free trailer pair.",
        "Real-composition runs used `pnpm dev -- -p 38917` without --fresh, on the worktree's own examples/app-showcase/.objectstack. Every server was stopped by its recorded PID tree; the port ended with 0 listeners, and the DB directory was deleted. The worktree was removed (node_modules first, no --force) after the last push."
      ],
      "files_changed": [
        "packages/services/service-datasource/src/plugin.ts (round 0, unchanged this round)",
        "packages/services/service-datasource/src/external-datasource-service.ts (Q2: skip unprovisionedInjectedColumns)",
        "packages/services/service-datasource/src/__tests__/external-import-saves-through-metadata-door.test.ts (round 0)",
        "packages/services/service-datasource/src/__tests__/external-validate-injected-anchors.test.ts (new)",
        "packages/qa/dogfood/test/external-import-saves-like-meta.dogfood.test.ts (new, 3 cases)",
        "packages/qa/dogfood/package.json (one dependency line)",
        "packages/qa/dogfood/vitest.config.ts (one source alias)",
        "pnpm-lock.yaml (importer entry, regenerated)",
        ".changeset/21788-external-import-saves-like-meta.md (minor, banner, Clause-② no (narrowing), ADR-0087 marker)"
      ],
      "patch_round_1_markdown": "## Patch round 1\n\nBuilt against seat verdict `5990234116` (Q1 → A, Q2 → A, Q3 → `Clause-②: no (narrowing)`) on the same claim `5989078646`. `#21841` (the import route's `?force` prescription) and `#21842` (the validate door) are not addressed here.\n\n**Q1: the card's dogfood pin is committed.** `packages/qa/dogfood/test/external-import-saves-like-meta.dogfood.test.ts` has three cases: an import under a name that differs from its remote table serves the remote rows; the same-name control serves its rows; and after a cold boot on the same database file both still serve their rows. To make that possible:\n- `@objectstack/service-datasource` is declared in `packages/qa/dogfood/package.json`;\n- its lockfile importer entry was regenerated by `pnpm install`. The same run also wrote `deprecated:` lines onto twelve `@yuku-analyzer/binding-*` packages, which is current registry metadata; none of it was hand-edited;\n- `packages/qa/dogfood/vitest.config.ts` aliases `@objectstack/service-datasource` to source, in the anchored array form `check:test-source-alias` reads.\nNo other `packages/qa` file is edited. The meta read-back case did not discriminate under the ablation and is dropped.\n\n**Q2: validation skips the platform's unprovisioned injected anchors.** `validateObjectUsing` (`external-datasource-service.ts`) now skips the columns `unprovisionedInjectedColumns(obj)` names. It reuses the spec's own provenance verdict and builds no second list. This also closes the boot abort for federated objects saved through `PUT /api/v1/meta/object/:name`, which `main` has today, not only for imports. A field an author declares under an anchor's name is the author's own field and is still compared.\n\n**Q3:** the changeset is now `minor`, with a `fix(service-datasource)!:` title, a **BREAKING** banner, `Clause-②: no (narrowing)`, and one line on what to do when a re-import or a name is refused. The PR body's line-start declaration should read `Clause-②: no (narrowing)`. The seat writes the body.\n\n**Measured at `8d640fa2`** (this branch after merging `origin/main` `8832655a`):\n\n| check | result |\n|:--|:--|\n| `@objectstack/service-datasource` suite / `tsc --noEmit` | 37 files, 717 tests pass / exit 0 |\n| new `external-validate-injected-anchors.test.ts` | 4 pass: the fixture carries the five anchors (premise case); a stored object carrying them validates `ok` on `validateObject` and `validateDatasource`; **negative control**: a declared `loyalty_tier` the remote lacks is `missing_column` at `error`, and is the only diff; an author-declared `owner_id` is still compared |\n| dogfood pin (3 cases) / `@objectstack/dogfood` `tsc --noEmit` | 3 pass / exit 0 |\n| ablation 1, `persistObject` put back to register-only (`scripts/ablation-replace.mjs`) | dogfood pin 3 of 3 red (`404 OBJECT_NOT_FOUND`), with no rebuild, because the alias reaches source; restored blob `d1d6a114` = HEAD, pin 3 of 3 green |\n| ablation 2, the anchor skip removed | validation pin 3 failed and 1 passed: the ok case, and the two negative controls, which then show 6 and 5 diffs (the anchors return beside the real one); the fixture-premise case stays green. Restored blob `4e7b236e` = HEAD, 4 of 4 green; `git status` clean |\n| `pnpm dev` showcase: runtime datasource `ext_fail` with the default `onMismatch: 'fail'`; `ef_cust` imported there, `ef_meta` saved through `PUT /meta/object`; restart | boots; `POST /datasources/ext_fail/external/validate` gives `ok: true` for `ef_cust`, `ef_meta` and `ef_cust_v2` |\n| same database plus `ef_drift` (`PUT /meta/object`, declares `loyalty_tier`); restart | boot aborts: \"Object 'ef_drift' does not match its remote table on datasource 'ext_fail'\", `missing_column: customers.loyalty_tier` only, no anchors |\n| the changeset's handling line, measured | a destructive re-import gives 400 (`?force=true` on the import returns the same 400, which is #21841); import under a new `name` gives 201 and rows; `PUT /meta/object/ef_cust?force=true` gives 200 (409 without `force`); an import named after an existing object with different fields (`showcase_ext_customer`, `ef_meta`) gives 400 destructive change |\n| gates | `dispatch-gates --commands` (no paths): 78 derived, all exit 0; `--ran`: 78 of 78, 0 NOT-MEASURED. `check-adr-0087-registration --base origin/main`: \"1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … not-required (no-migration-prescription)\"; `check-changeset-no-major --base origin/main`: \"no `major` bump\" |\n| narrowed eslint (`--no-inline-config`, the 6 changed `.ts` files) | 6 files, 0 findings; the config is not type-aware |\n\n**Acceptance notes:**\n- `persistCatalog` (the `external_catalog` snapshot) still goes through `metadata.register` only, and is decided at `init()`. Under the verify harness it is never wired. Its durability on `pnpm dev` is NOT MEASURED.\n- An imported object's history row records no actor (`recorded_by` null), measured. The door records the caller's user id. The import route does not pass its caller to `IExternalDatasourceService.importObject`, and that contract has no actor parameter.\n"
    }

    Generated by Claude Code

  7. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · seat domain:services#1 · session_011K3zqE8Pv1Evw5hc8tZCnN · 2026-10-05T10:08Z


    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

    area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingdomain:servicespriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions