Skip to content

Commit da22e1d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24197-adr-0079-note
2 parents 2950277 + 2b7fc52 commit da22e1d

15 files changed

Lines changed: 1176 additions & 71 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): an anonymous caller of `/api/v1/mcp` is answered `401 UNAUTHENTICATED` whatever `OS_MCP_SERVER_ENABLED` or the `mcp` service says
6+
7+
Clause-②: no
8+
9+
ADR-0138 D2 takes the anonymous decision once per domain, as the domain's first statement. The `/mcp` domain checked the `OS_MCP_SERVER_ENABLED` switch and the `mcp` service before it, so an anonymous caller was answered `404 RESOURCE_NOT_FOUND` with the switch off and `501 NOT_IMPLEMENTED` with no MCP service installed, and learned whether the deployment runs MCP before it was refused. Measured on both hosts, the standalone server (`HonoServerPlugin` with the dispatcher plugin) and a `createHonoApp` host.
10+
11+
- An anonymous request on `/api/v1/mcp` (any verb) is now refused `401 UNAUTHENTICATED` first, in every composition. Only the order of the refusals moves: no request that was refused before is accepted.
12+
- A signed-in caller, or a system principal, keeps the switch's `404` and the service's `501`, in that order.
13+
- The `401` advertises RFC 9728 resource metadata (`WWW-Authenticate: Bearer … resource_metadata="…"`) only where the resource is served: the switch on, an `mcp` service that can take the request, and an auth service that answers a metadata URL. With the switch off or no service the `401` is the plain API-key one, so it never points a client at a protected resource the deployment does not serve.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): `filter-token-unknown` states what the runtime does with an unresolvable filter placeholder: the data engine refuses the query with `FILTER_TOKEN_UNKNOWN` / 400
6+
7+
Clause-②: no
8+
9+
The finding's message said the value "is sent to the data engine as a literal string, matches no record, and the surface renders empty". The runtime does not do that. The data engine refuses a query that carries an unresolvable placeholder, `FILTER_TOKEN_UNKNOWN` with HTTP 400, rather than compare it as a literal string. The message now reads:
10+
11+
```text
12+
Filter value "{current_user}" is not a resolvable placeholder. The data engine refuses a query that carries it (`FILTER_TOKEN_UNKNOWN`, 400), so the surface never shows the data the filter meant.
13+
```
14+
15+
Unchanged: the rule id, its severity (`error`), the finding's `path`, its `hint` (the `fix:` line), and which values the rule reports. The message's first sentence is unchanged as well. `os explain filter-token-unknown` already stated the refusal and is unchanged.

‎.github/workflows/scheduled-full-run-card.yml‎

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,19 @@ name: Scheduled Full Run Card
4242
# REFUSES rather than filing. The oldest match wins — it is the one
4343
# any duplicates were closed against, and the one the devx seat
4444
# already graded.
45-
# REFRESH the body is rewritten in place, never a comment per run. ⛔ Labels
46-
# are applied on CREATE only: grading is the seat's and a refresh
47-
# must not undo it.
45+
# REFRESH the body is rewritten in place, never a comment per run. ⛔ Grade
46+
# labels (priority, lane, type) are applied on CREATE only:
47+
# grading is the seat's and a refresh must not undo it. The one
48+
# label a refresh may move is a STATE: a card still `pm:blocked`
49+
# whose every `Blocked-by:` target (body or comments) is a LOCAL
50+
# card that has closed goes back to `pm:queue` — a new red after
51+
# the last blocker's fix landed on this `main` is new work, and
52+
# `pm:blocked` hides it from every seat. ⛔ A closed CROSS-REPO
53+
# target never releases it: closed there is merged upstream, not
54+
# consumable here through the `.objectui-sha` pin. That, any
55+
# target open or unreadable, no target and an assignee are among
56+
# the cases where no label moves; every case and its reason:
57+
# "A STALE `pm:blocked`" in the module.
4858
# BODY the run link, and the commits between the previous GREEN
4959
# scheduled run of THIS workflow and this one. That range is the
5060
# hour in which `main` broke, and an EMPTY range is a reading of
@@ -113,7 +123,7 @@ jobs:
113123
permissions:
114124
actions: read # list the previous green scheduled run
115125
contents: read # checkout, and the compare API
116-
issues: write # the card, and nothing else
126+
issues: write # the card (body, and the stale-block label swap) and the reads that judge it
117127
steps:
118128
- name: Checkout repository
119129
uses: actions/checkout@v7
@@ -214,11 +224,13 @@ jobs:
214224
215225
// The DECISION is the module's, driven offline by its self-test
216226
// against a mutable board (one red run files one card; the next
217-
// refreshes it and files no second). What is left here is three
218-
// callbacks over the API.
227+
// refreshes it and files no second; a refresh of a card whose
228+
// every blocker is a closed LOCAL card moves `pm:blocked` to
229+
// `pm:queue`). What is left here is thin callbacks over the API.
219230
const outcome = await mod.fileOrRefreshCard({
220231
identity,
221232
body,
233+
ownerRepo: `${owner}/${repo}`,
222234
perPage: 100,
223235
maxPages: 10,
224236
listPage: async (page) => {
@@ -236,10 +248,31 @@ jobs:
236248
const res = await github.rest.issues.create({ owner, repo, title, body: b, labels });
237249
return { number: res.data.number };
238250
},
251+
// Read only for a card carrying `pm:blocked`.
252+
listComments: async ({ number, page, perPage }) => {
253+
const res = await github.rest.issues.listComments({
254+
owner, repo, issue_number: number, per_page: perPage, page,
255+
});
256+
return res.data;
257+
},
258+
getIssue: async ({ repo: target, number }) => {
259+
const [targetOwner, targetRepo] = target.split('/');
260+
const res = await github.rest.issues.get({ owner: targetOwner, repo: targetRepo, issue_number: number });
261+
return { state: res.data.state, closed_at: res.data.closed_at };
262+
},
263+
// The swap: an additive POST and ONE remove by name.
264+
addLabels: async ({ number, labels }) => {
265+
await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels });
266+
},
267+
removeLabel: async ({ number, name }) => {
268+
await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name });
269+
},
239270
});
240271
241272
core.info(`${outcome.action} ${identity.marker} card #${outcome.number} (${body.length} chars)`);
242-
core.notice(`${run.name} is red on the hourly full run — card #${outcome.number} ${outcome.action}.`);
273+
if (outcome.block) core.info(`block: ${outcome.blockSummary}`);
274+
const moved = outcome.block === 'expired' ? ' (every blocker closed: pm:blocked moved to pm:queue)' : '';
275+
core.notice(`${run.name} is red on the hourly full run — card #${outcome.number} ${outcome.action}${moved}.`);
243276
core.setOutput('action', outcome.action);
244277
core.setOutput('number', String(outcome.number));
245278

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ about the only thing left to do: **connecting a client and proving it works**.
1212

1313
<Callout type="info">
1414
To turn the surface off, set `OS_MCP_SERVER_ENABLED=false` — the endpoint then
15-
returns 404 and the **Connect an Agent** page disappears with it, along with
16-
both its Setup and Account navigation entries. See
15+
returns 404 to a caller with an API key or token (an anonymous caller still gets
16+
401 first, with no OAuth metadata advertised), and the **Connect an Agent** page
17+
disappears with it, along with both its Setup and Account navigation entries. See
1718
[environment variables](/docs/deployment/environment-variables#mcp-server).
1819
</Callout>
1920

@@ -380,8 +381,8 @@ skill and a guided `/objectstack:connect` command.
380381
| **✘ Failed to connect** / `ConnectionRefused` on a server that used to work | The app is not running. Start it — the client reconnects on its own, no need to re-register |
381382
| The server is missing from `/mcp` in a running session | Sessions read MCP config at start — open a new one. If it is still missing, you registered at **local** scope from another directory; re-add with `-s user` |
382383
| `Object 'x' not found` on `query_records` / `get_record` | Wrong object name — pass the API name from `list_objects` in **`objectName`** (`crm_opportunity`, not `opportunity`) |
383-
| `404` on `/api/v1/mcp` | The HTTP surface is disabled — unset `OS_MCP_SERVER_ENABLED` (default is on) |
384-
| `501 Not Implemented` | The MCP plugin isn't part of this build — check your stack's plugins |
384+
| `404` on `/api/v1/mcp` | The HTTP surface is disabled — unset `OS_MCP_SERVER_ENABLED` (default is on). A client with an API key or token sees the `404`; an anonymous client, including an OAuth client before it signs in, sees a plain `401` with no `resource_metadata` in `WWW-Authenticate` |
385+
| `501 Not Implemented` | The MCP plugin isn't part of this build — check your stack's plugins. A client with an API key or token sees the `501`; an anonymous client sees a plain `401` with no `resource_metadata` |
385386
| `mcp` missing from `GET /api/v1/discovery` and no Connect-an-Agent card, but `OS_MCP_SERVER_ENABLED` is on | The same cause as the `501` above, seen from the other side: the surface is *enabled* but not *serveable*, so discovery declines to advertise a route that would 501 rather than over-promising it (`declared === enforced`). Load the MCP plugin — `os serve` / `os dev` do it for you; a host that embeds `@objectstack/rest` directly must add `@objectstack/mcp` itself |
386387
| stdio won't start / boot fails closed | `OS_MCP_STDIO_ENABLED=true` but `OS_MCP_STDIO_API_KEY` is missing, unknown, revoked, or expired — fail-closed by design (ADR-0101). Set a valid `osk_` key; there is no unscoped or `system` fallback |
387388
| `401` on every call | Anonymous or invalid credentials. Interactive clients: complete the browser login (the `WWW-Authenticate` header advertises the OAuth metadata). Headless: check the `osk_` key and header spelling |

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ OS_MCP_STDIO_ENABLED=true OS_MCP_STDIO_API_KEY=osk_... os start # start the lo
268268

269269
| Variable | Type | Default | Description |
270270
|:---|:---|:---|:---|
271-
| `OS_MCP_SERVER_ENABLED` | boolean | `true` | The MCP **HTTP** surface (`/api/v1/mcp`) is a core capability and defaults **on**. Set `false` to disable it (endpoint 404s, the Connect-an-Agent page disappears). |
271+
| `OS_MCP_SERVER_ENABLED` | boolean | `true` | The MCP **HTTP** surface (`/api/v1/mcp`) is a core capability and defaults **on**. Set `false` to disable it (the endpoint answers 404 to a caller with an API key or token and 401 to an anonymous one, and the Connect-an-Agent page disappears). |
272272
| `OS_MCP_STDIO_ENABLED` | boolean | `false` | Auto-start the long-lived **stdio** transport at boot. Opt-in and **stricter** than the HTTP surface. **Requires `OS_MCP_STDIO_API_KEY`** — stdio runs as that key's identity with RLS/FLS/tenant applied; if the key is missing or invalid, boot **fails closed** (stdio refuses to start). See ADR-0101. |
273273
| `OS_MCP_STDIO_API_KEY` | string | — | The `osk_...` API key the **stdio** transport runs as. Resolved through the same verify chain as the HTTP/REST surfaces, so reads are scoped to that identity's permissions. Mint one from the **Connect an Agent** page — **Account → Developer** for any signed-in user, **Setup → Connect an Agent** for platform admins — or `POST /api/v1/keys`. For full authority, mint a key on a platform-admin or dedicated **service** identity — there is deliberately no `system`/unscoped bypass. |
274274
| `OS_MCP_SERVER_NAME` | string | `objectstack` | Server name advertised to MCP clients. |

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -914,9 +914,10 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
914914
'saves, a rollout decision over stored tenant rows the in-repo corpus does not measure.',
915915
run: (stack) => validateDashboardWidgetOptions(stack),
916916
},
917-
// #3574 — a filter value like `{current_user}` resolves in no vocabulary,
918-
// reaches the data engine as a literal and matches nothing. The surface
919-
// renders empty with no error, and a silent zero is indistinguishable from a
917+
// #3574 — a filter value like `{current_user}` resolves in no vocabulary. The
918+
// data engine refuses a query carrying it (`FILTER_TOKEN_UNKNOWN`, 400). When
919+
// #3574 was filed it reached the engine as a literal and matched nothing: the
920+
// surface rendered empty with no error, a silent zero indistinguishable from a
920921
// genuine one at review time.
921922
{
922923
name: 'validateFilterTokens',

‎packages/lint/src/validate-dataset-references.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,11 @@
3333
*
3434
* ## Why a dataset's dangling reference is worse than most
3535
*
36-
* `filter-token-unknown`'s own wording states the failure mode it exists to
37-
* prevent: the value is *"sent to the data engine as a literal string, matches
38-
* no record, and the surface renders empty."* A dangling field path produces
39-
* the same outcome from the same node.
36+
* `filter-token-unknown` judges the VALUE at that same node, and the value it
37+
* reports does not fail quietly: the data engine refuses the query
38+
* (`FILTER_TOKEN_UNKNOWN`, 400). A dangling field path there is the quiet
39+
* failure. By the severity note below, every outcome it produces reaches a
40+
* human as a chart that rendered successfully.
4041
*
4142
* A dataset is the semantic layer — dashboards and reports bind its dimensions
4243
* and measures BY NAME (ADR-0021) — and the CONSUMER end of that binding is

‎packages/lint/src/validate-filter-tokens.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ describe('validateFilterTokens', () => {
5656
expect(findings[0].hint).toContain('{current_user_id}');
5757
});
5858

59+
// [#24293] The message states the runtime's answer: the data engine refuses a
60+
// query carrying the value (`UnknownFilterTokenError` in `@objectstack/core`,
61+
// `FILTER_TOKEN_UNKNOWN` / 400). Held as that code, the named subject, not as
62+
// the prose around it.
63+
it('names the code the data engine refuses the query with, near miss or not', () => {
64+
const findings = validateFilterTokens({
65+
dashboards: [{ name: 'd', widgets: [{ id: 'w', filter: { a: '{current_user}', b: '{whoever_is_watching}' } }] }],
66+
});
67+
expect(findings.map((f) => f.path)).toEqual(['dashboards[0].widgets[0].filter.a', 'dashboards[0].widgets[0].filter.b']);
68+
for (const f of findings) expect(f.message, f.path).toContain('`FILTER_TOKEN_UNKNOWN`');
69+
});
70+
5971
it('accepts the correct spelling alongside date macros', () => {
6072
expect(
6173
validateFilterTokens({

‎packages/lint/src/validate-filter-tokens.ts‎

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,27 @@ import { recordsOf } from './object-graph.js';
2222
* Exactly two vocabularies resolve inside a filter value: context tokens
2323
* (`{current_user_id}`, `{current_org_id}` — see `context-tokens.zod.ts`) and
2424
* date macros (`{today}`, `{30_days_ago}` — see `date-macros.zod.ts`).
25-
* Anything else is passed to the data engine **verbatim**, matches no row, and
26-
* the surface renders an empty result.
25+
* Anything else does not resolve: the data engine refuses a query that carries
26+
* it (`FILTER_TOKEN_UNKNOWN`, 400 — `UnknownFilterTokenError` in
27+
* `@objectstack/core`'s `filter-tokens.ts`), so the surface never shows the
28+
* data the filter meant.
2729
*
2830
* ## Why this is an error, not a warning
2931
*
30-
* The runtime failure mode is silent and indistinguishable from success. A
31-
* metric widget filtered on an unresolved `{current_user}` renders `0`, which
32-
* looks exactly like a metric that is legitimately zero — no console error, no
33-
* server log, nothing for a human reviewer to notice. Issue #3574 found a
34-
* dashboard that had been broken this way since the day it was written.
32+
* The rule is older than that refusal. When issue #3574 was filed, the runtime
33+
* substituted no placeholder: the value reached the data engine verbatim and
34+
* matched no row. A metric widget filtered on an unresolved `{current_user}`
35+
* rendered `0`, which looks exactly like a metric that is legitimately zero —
36+
* no console error, no server log, nothing for a human reviewer to notice.
37+
* Issue #3574 found a dashboard that had been broken this way since the day it
38+
* was written. The runtime now refuses the query instead, so the failure is no
39+
* longer silent, but it still shows only when someone opens the surface.
3540
*
36-
* That failure mode is worse for AI authors than for humans. An AI reads a
37-
* successful query returning `0` as a correct answer and builds on it — it has
38-
* no instinct that the number looks wrong. Its correction loop is
39-
* "author → validate → fix", so a diagnostic only reaches it if the diagnostic
40-
* can fail the build. A runtime warning in a server log is invisible to it.
41+
* Neither form reaches an AI author. An AI read the old `0` as a correct answer
42+
* and built on it — it has no instinct that the number looks wrong. Its
43+
* correction loop is "author → validate → fix", so a diagnostic only reaches it
44+
* if the diagnostic can fail the build. A server log line, or a refusal that
45+
* arrives when a surface is opened after the build passed, is invisible to it.
4146
* Hence: authoring-time error.
4247
*
4348
* The near-miss spellings this catches are not hypothetical. Each is a correct
@@ -103,7 +108,7 @@ export const FILTER_TOKEN_UNKNOWN = 'filter-token-unknown';
103108
export type FilterTokenSeverity = 'error' | 'warning';
104109

105110
export interface FilterTokenFinding {
106-
/** Always `error` today — an unresolved placeholder silently matches nothing. */
111+
/** Always `error` today — the data engine refuses a query carrying an unresolvable placeholder. */
107112
severity: FilterTokenSeverity;
108113
/** Diagnostic rule id. */
109114
rule: string;
@@ -233,8 +238,9 @@ function walkFilterValues(
233238
where,
234239
path,
235240
message:
236-
`Filter value "${node}" is not a resolvable placeholder. It is sent to the ` +
237-
`data engine as a literal string, matches no record, and the surface renders empty.`,
241+
`Filter value "${node}" is not a resolvable placeholder. The data engine refuses a ` +
242+
`query that carries it (\`FILTER_TOKEN_UNKNOWN\`, 400), so the surface never shows ` +
243+
`the data the filter meant.`,
238244
hint: suggestion && isRecordContextToken(suggestion)
239245
? `Did you mean "{${suggestion}}"? It resolves only in a filter on a component of a ` +
240246
`\`type: 'record'\` page, to the id of the record in view.`

0 commit comments

Comments
 (0)