Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/qa-http-adapter-mount-discovery.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
"@objectstack/core": patch
---

fix(qa): `HttpTestAdapter` resolves the Data Protocol mount from the server's `/discovery`, and falls back to the convention loudly (#7983)

The record-shaped `os test` action types (`create_record`, `read_record`,
`update_record`, `delete_record`, `query_records`) built their URLs from the
**defaults** of `RestApiConfigSchema.apiPath` and
`CrudEndpointsConfigSchema.dataPrefix`, because the adapter is handed an origin
and nothing else. A deployment that moved the mount got a 404 that reads like the
suite author's own URL mistake rather than a platform limitation.

The adapter now asks the server, following the `getRoute` precedent in
`@objectstack/client`: **one memoised `GET {apiBase}/discovery` per run** (`os
test` builds one adapter for the whole run), addressing whatever `routes.data`
advertises, with the schema-derived convention as the fallback. Measured on a
booted stack (REST route generator + dispatcher bridge), before and after:

| deployment | before | after |
|---|---|---|
| stock | created | created |
| `crud.dataPrefix: '/objects'` | `HTTP Error 404` | created |
| `api.apiPath: '/api/2026-01'` | `HTTP Error 404` | `HTTP Error 404`, now naming the mount |

The `apiPath` row is **not** closed, and the reason is structural: `apiPath`
moves the base that `/discovery` is itself mounted under, so the document that
would name the new mount sits behind the prefix that is missing. The one
discovery document at a fixed path does not rescue it — `/.well-known/objectstack`
advertises the **dispatcher's** `${prefix}/data`, measured as `/api/v1/data`
under all three configs above — so it is deliberately not probed: trusting it
would attach a false provenance ("discovery told us") to the same 404.

Instead that case degrades loudly. Falling back to the convention prints a
warning naming the mount it will address, the probe that failed and the remedy,
and every 404/405 from a record action now carries the mount it addressed and
where that mount came from. `api_call` is unchanged, issues no probe, and remains
the escape hatch for a host the probe cannot reach.
21 changes: 16 additions & 5 deletions content/docs/deployment/cli.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1054,11 +1054,22 @@ success forever. Pass **`--fail-on-empty`** to opt into the strict reading, wher
an empty match exits 1 (#7848).

The **record-shaped** action types — `create_record`, `read_record`,
`update_record`, `delete_record`, `query_records` — address the Data Protocol at
its default mount (`/api/v1/data`, i.e. `{apiPath}{crud.dataPrefix}`). A
deployment that moves that mount by setting `api.apiPath` or `crud.dataPrefix` is
out of reach for them; write those steps as `api_call`, which takes the path you
give it. `run_script` has no adapter branch at all and fails by name.
`update_record`, `delete_record`, `query_records` — **ask the server where the
Data Protocol is mounted** instead of assuming it. Once per run, `os test`
fetches `{apiBase}/discovery` and addresses whatever `routes.data` advertises,
so a deployment that moves the mount with `crud.dataPrefix` is reached without
you telling it anything. When the probe cannot answer, the run falls back to the
convention `{apiPath}{crud.dataPrefix}` (`/api/v1/data`) and **says so**: a
warning naming the mount it will address and the probe that failed, and the same
statement appended to every 404 a record step gets — so a wrong mount reads as a
wrong mount, not as your own URL mistake.

One case survives that fallback by construction: setting **`api.apiPath`** moves
the discovery document itself out from under the probe, and no fixed-path
document reports the REST mount (`/.well-known/objectstack` advertises the
dispatcher's own prefix, not this one). Against such a host, write those steps as
`api_call`, which takes the path you give it. `run_script` has no adapter branch
at all and fails by name.

#### `os doctor`

Expand Down
9 changes: 5 additions & 4 deletions docs/qa/platform-checklist/areas/cli.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -433,7 +433,7 @@
"title": "os test: a Quality Protocol suite is validated at LOAD, executed against a booted app, and its verdict is the exit code — capture/interpolation thread state, an unevaluable assertion FAILS",
"since": "v17",
"status": "active",
"revision": 2,
"revision": 3,
"priority": "P1",
"surface": "cli",
"personas": ["operator (local shell)", "suite author (writes qa/*.test.json)"],
Expand All@@ -447,7 +447,7 @@
],
"knownGaps": [
"`run_script` has no adapter branch and fails by name (`Unsupported action type in HttpAdapter: run_script`) — the variant sweep records ONE refusal and that is the honest verdict, not a fixture gap to work around. The other seven members execute since #7848; the five record-shaped ones did not until then (see `negative`), so a sweep transcript predating that fix shows five 404s and is not comparable",
"the record action types address the Data Protocol at its DEFAULT mount only (`{apiPath}{crud.dataPrefix}` = `/api/v1/data`). A deployment that sets `api.apiPath` or `crud.dataPrefix` moves the mount out from under them; the fixture boot uses stock config, so this does not bite here — a run against a re-prefixed host must write those steps as `api_call`",
"the record action types resolve the Data Protocol mount from the server's own `/discovery` (`routes.data`) since #7983, so a deployment that sets `crud.dataPrefix` IS reached; what remains out of reach is `api.apiPath`, which moves the discovery document itself out from under the probe (measured: `{apiBase}/discovery` 404s, and `/.well-known/objectstack` reports the DISPATCHER's `/api/v1/data`, not the REST mount). Against such a host the adapter falls back to the convention and says so — it names the mount it addressed, the probe that failed and the remedy, on the warning AND on every 404 — and those steps must still be written as `api_call`. The fixture boot is stock, so neither case bites here",
"no scenario SELECTION exists: `os test` has exactly two flags (--url, --token), `scenario.tags` filters nothing and `scenario.requires` is never checked (packages/spec/liveness/qa.json rows `tags`/`requires`), so the whole glob always runs and a suite cannot declare a precondition it will be skipped for"
]
},
Expand DownExpand Up@@ -541,7 +541,7 @@
"source": [
"packages/cli/src/commands/test.ts (the shipped `os test`: the #7363 lazy segment-directed glob with its prune list, `loadTestSuite`'s #6247 boundary parse, the per-scenario report, and the exit 0/1 summary)",
"packages/core/src/qa/runner.ts (scenario sequencing, `capture` + `{{var}}` interpolation, the assertion operators, setup/teardown semantics, the #7256 unevaluable-`contains` fix)",
"packages/core/src/qa/http-adapter.ts (the action-type switch — its case labels ARE the enum values; the record routes derive their prefix from RestApiConfigSchema + CrudEndpointsConfigSchema rather than spelling `/api/v1/data` out, #7848)",
"packages/core/src/qa/http-adapter.ts (the action-type switch — its case labels ARE the enum values; the record routes take their prefix from the one memoised `/discovery` probe per run, falling back to the RestApiConfigSchema + CrudEndpointsConfigSchema convention with a diagnostic that names the mount, #7848 / #7983)",
"packages/spec/src/qa/testing.zod.ts (TestSuiteSchema — the shape enforced at load; TestActionTypeSchema pinned above)",
"packages/spec/liveness/qa.json (the ADR-0049 ledger whose existence this item is coverage.json's mapping for — its dead `tags`/`requires` rows are why no scenario selection exists)",
"packages/cli/test/qa-suite-schema-load.test.ts, packages/cli/test/resolve-glob-lazy-walk.test.ts, packages/core/src/qa/runner.test.ts (the three unit pins — cited so a run knows what is already covered, NOT a substitute for driving a booted app)",
Expand All@@ -550,7 +550,8 @@
],
"history": [
{ "revision": 1, "date": "2026-08-11", "change": "new item: the `qa` capability's coverage.json mapping, authored rather than waived (#7347 triage ruling). `os test` is a shipped, documented CLI command, so the honest mapping is a surface:cli item that authors a real qa/*.test.json suite and drives it against a booted app — the fixture suite examples/app-showcase/qa/platform-smoke.test.json lands with this item and is the repo's first Quality Protocol suite. Every clause was measured on showcase before it was written: the green path, capture/interpolation, the #6247 load refusal, the #7256 unevaluable-contains failure, teardown-after-failure, the 8-member action-type sweep and the #7363 glob. `since: v17` records the release in which the surface became GOVERNED (liveness ledger seeded + TestSuiteSchema enforced at the load site, #6247 / PR #7255); the command itself predates it. No `automated` entry: the three unit pins cover pieces, none of them proves a suite reaches a real server", "ref": "#7347" },
{"revision": 2, "date": "2026-08-12", "change": "the adapter was repaired, which this item's own `negative` clause declared to be a revision rather than a silent green (#7848). Item 1: the five record-shaped action types now round-trip against a stock server — the `${baseUrl}/api/data/:object` literal became a prefix DERIVED from the two schemas RestServer itself resolves from (RestApiConfigSchema `apiPath ?? {basePath}/{version}` + CrudEndpointsConfigSchema.dataPrefix), and `update_record` PATCHes where it used to PUT a route that has no PUT sibling. Re-measured on a booted showcase, one scenario per member with NO shared setup so no member's verdict is inferred from a sibling: 7 of 8 execute and assert, `run_script` still refuses by name. The `negative` clause is inverted accordingly — a 404 from a record action type is the regression now — and the knownGap it rested on is replaced by the narrower one that survives: the record types address the DEFAULT mount only, so a host that moves it with `api.apiPath`/`crud.dataPrefix` still needs `api_call`. Item 2: a zero-match glob still exits 0, deliberately (a repo that legitimately ships no suites must not start failing CI), but the posture is now DECLARED — stated in `--help`, opt out with the new `--fail-on-empty`, and `Found N test suites.` is emitted on EVERY run including `Found 0 test suites.`, which is the line this item's first acceptance clause already asks a run record to quote", "ref": "#7848"}
{"revision": 2, "date": "2026-08-12", "change": "the adapter was repaired, which this item's own `negative` clause declared to be a revision rather than a silent green (#7848). Item 1: the five record-shaped action types now round-trip against a stock server — the `${baseUrl}/api/data/:object` literal became a prefix DERIVED from the two schemas RestServer itself resolves from (RestApiConfigSchema `apiPath ?? {basePath}/{version}` + CrudEndpointsConfigSchema.dataPrefix), and `update_record` PATCHes where it used to PUT a route that has no PUT sibling. Re-measured on a booted showcase, one scenario per member with NO shared setup so no member's verdict is inferred from a sibling: 7 of 8 execute and assert, `run_script` still refuses by name. The `negative` clause is inverted accordingly — a 404 from a record action type is the regression now — and the knownGap it rested on is replaced by the narrower one that survives: the record types address the DEFAULT mount only, so a host that moves it with `api.apiPath`/`crud.dataPrefix` still needs `api_call`. Item 2: a zero-match glob still exits 0, deliberately (a repo that legitimately ships no suites must not start failing CI), but the posture is now DECLARED — stated in `--help`, opt out with the new `--fail-on-empty`, and `Found N test suites.` is emitted on EVERY run including `Found 0 test suites.`, which is the line this item's first acceptance clause already asks a run record to quote", "ref": "#7848"},
{"revision": 3, "date": "2026-08-17", "change": "the record action types stopped ASSUMING the mount (#7983). They now resolve it from the server: one memoised `GET {apiBase}/discovery` per run, addressing whatever `routes.data` advertises, with the RestApiConfigSchema + CrudEndpointsConfigSchema convention as the fallback — the `@objectstack/client` `getRoute` pattern, copied rather than re-invented. Measured on a booted stack (REST generator + dispatcher bridge) before and after, three configs: stock stays green; `crud.dataPrefix: '/objects'` went from `HTTP Error 404` to a created record, so that row of the gap is CLOSED; `api.apiPath: '/api/2026-01'` still 404s and is NOT closed — `apiPath` moves the discovery document itself, and the one fixed-path document (`/.well-known/objectstack`) advertises the dispatcher's `/api/v1/data` under all three configs, so trusting it would attach a false provenance to the same 404. That row is narrowed instead: the fallback is announced (a warning naming the mount, the failed probe and the remedy) and every 404 from a record action now carries the mount it addressed and where that mount came from, so the failure can no longer read as the suite author's own URL mistake. `api_call` is unchanged and probes nothing — it remains the escape hatch for a host the probe cannot reach", "ref": "#7983"}
]
},
{
Expand Down
Loading
Loading