Skip to content

Commit e1d50d5

Browse files
committed
Merge origin/main (#30946 landed) into claude/project-thread-y3qc8b; the tenant-audit census re-measured
#30946 moved main's census to 264. Took main's side of both census files, re-ran node scripts/tenant-audit-census.mjs --write (265 with the export writer's site) and restated the page's six hand-written figures. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibwj6uu1L1hVh8hXHro
2 parents 4addb47 + e1dac19 commit e1d50d5

76 files changed

Lines changed: 5893 additions & 97 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/console': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(console): a settings field an environment variable pins offers to clear the value saved beneath the pin
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **`@object-ui/console`:** on a Setup settings page, a field an `OS_*` environment variable pins still shows the lock badge and stays read-only. When the namespace read lists a value stored beneath the pin at the scope the page writes (a secret's as the read mask), the row also says that a value is saved there and that the environment value stays in force, and offers "Clear the saved value". The control asks first, then writes `null` for that one key, the one write the server lets through a pin; the page's unsaved edits are left as they are. After the clear the row still reads the environment value, and the saved value no longer comes back when the variable is removed. A pinned field with nothing stored at that scope, a field no variable pins, and the first-run checklist show no control.
12+
- **`@object-ui/i18n`:** four new `console.settingsView` keys in all eleven packs: `savedBeneathPin`, `clearSaved`, `clearSavedConfirm` and `savedCleared`.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/mcp': patch
3+
'@objectstack/spec': patch
4+
'@object-ui/console': patch
5+
---
6+
7+
feat(mcp): `delete_records` deletes many records of one object in one confirmed call, by a filter or an id list (#30891)
8+
9+
Clause-②: yes (widening) — the MCP surface (HTTP `/api/v1/mcp` and the stdio server) registers one more tool, `delete_records`, offered to an OAuth token that carries both `data:read` and `data:write`; no existing tool, input or answer changes.
10+
Metadata-protocol: none
11+
12+
An agent that had to clear a set of records called `delete_record` once per row, and confirmed each one. `delete_records` takes the selection as `where` (a filter, `{}` for every record) or `recordIds` (an id list), never both.
13+
14+
- **The preview is the refusal.** A call without `confirm: true` deletes nothing. It answers `delete_record`'s refusal (`428` `ACTION_CONFIRMATION_REQUIRED`, the same `details` keys, `details.actionName` naming `delete_records`) with a `preview` member beside `error`: how many records the selection matches for this caller (`matched`), how many a confirmed call attempts (`wouldAttempt`), up to ten sample ids and, for an id list, the ids that name no readable record (`notFound`). The agent shows that to its human and retries with `confirm: true` once they approve.
15+
- **A confirmed call deletes each record as `delete_record` does**, through the same bridge member, under the caller's permissions, row-level security and the object's delete rules (`beforeDelete` hooks, restricted relations). No caller bypasses a package's locking rule, an administrator included. Records a rule refuses stay, and the answer groups them by code, status and reason, with their ids: `{ matched, attempted, deleted, refused, refusals: [{ code, status, reason, count, recordIds }], remaining, cap }`. A refusal's `details` stays off the wire, as on every other tool.
16+
- **A fault is not a refusal.** A rule's refusal and a fault are told apart the way the REST `/data` door tells them apart (its own classifier): a 4xx verdict (a coded rule refusal, a sandboxed hook body's `400 HOOK_ABORTED`) is grouped and the call goes on; a 5xx verdict (a code hook's bare `throw new Error`, a lost connection, a driver error, a driver's own string code) stops the call at that record. The tool error names the fault and the record, and carries the call's `progress` (`deleted`, `stoppedAt`, `notAttempted`): the records deleted before it stay deleted.
17+
- **At most 200 records per call**, the bound REST `POST /data/:object/deleteMany` applies by default (`batch.maxBatchSize`). A filter is read in id order at the cap, and the count says whether more match; when more than 200 match, the answer says how many remain and the next call with the same selection continues. When the deployment has no aggregate support, the answer says `matchedMoreThan: 200` instead of guessing a count. On a deployment whose read page size or row budget (`OS_DATA_MAX_PAGE_SIZE`, `OS_DATA_QUERY_ROW_BUDGET`) is under 200, one call runs at that size, since the data door refuses a larger read, and the answer's `cap` names it; a longer id list is refused before anything is read.
18+
- **Scopes.** Over OAuth the tool needs both `data:read` and `data:write`: its preview reads the selection's ids and count, a read a `data:write`-only token is not granted.
19+
20+
What a preview cannot say: which records a delete rule will refuse. The engine has no delete dry run, so the rules are judged when the confirmed call runs, and the preview says so.
21+
22+
`@object-ui/console`: the Integrations page's "Download skill" SKILL.md is a byte-for-byte mirror of the server's (#31015's drift pin), so it carries the same `delete_records` bullet.
23+
24+
`@objectstack/spec` changes only doc comments: `AIActionConfirmation` names `delete_records` as the third door of the confirmation member, and the error-code ledger's `ACTION_CONFIRMATION_REQUIRED` rows name both delete tools.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(app-shell): Connect an Agent marks claude.ai (web) and Doubao cloud tasks as not available on an address that is not public HTTPS
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
claude.ai on the web and Doubao Work's cloud tasks connect from their vendor's servers, which reach only a public HTTPS address. When the address Connect an Agent shows is an `http://` address, `localhost`, a single-label or `.local` host, or a private, loopback or link-local IP, those two ways in now carry a "not available for this deployment" note with the reason in one sentence (naming the address) and what makes them available (serving the system at a public HTTPS address). Claude Desktop, Claude Code, Codex, Cursor and the other clients stay as they are, and on a public HTTPS address nothing changes. The five new strings ship in every console language pack, Traditional Chinese generated from Simplified.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/client': patch
3+
---
4+
5+
feat(client): `client.oauth.authorizedClients.list()` and `.listForUser(userId)` read the AI assistants a person has authorized and when each was last used
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
`list()` calls `GET /api/v1/auth/authorized-clients` with no parameter: the caller's own grants. `listForUser(userId)` calls `GET /api/v1/auth/admin/authorized-clients?userId=...` and is answered for a platform administrator only. Both unwrap the `{ success, data }` envelope to the new exported `AuthorizedClientsList` type, `{ clients: AuthorizedClient[]; truncated: boolean }`. `AuthorizedClient.lastUsedAt` is an hour start, `null` when no use has been recorded. A refusal throws, as every other method does. Nothing existing changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
feat(platform-objects): `sys_oauth_consent` gains `last_used_at`, the start of the most recent hour in which the client made a verified MCP call under the consent
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
A nullable, read-only datetime on the better-auth-managed consent table, owned by ObjectStack. The OAuth provider neither reads nor writes it. Its one writer is plugin-auth, server-side, after it verifies an MCP access token minted under the consent. The object stays off the data API (`apiEnabled: false`), so no client reaches the column. A withdrawn or revoked consent takes the stamp with it. Schema sync adds the column, and existing rows read `null`.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
feat(plugin-auth): a person can read the AI assistants they have authorized and when each was last used, and a platform administrator can read another person's
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
Two new reads, both ObjectStack mounts ahead of the better-auth catch-all and both in `AUTH_ROUTE_LEDGER`:
11+
12+
- `GET /api/v1/auth/authorized-clients` answers the signed-in person's own OAuth consents (Claude Desktop, an IDE agent, any MCP client). It is keyed by the session and nothing else: it takes no query parameter, and one that is sent (`?userId=` included) is refused `400 VALIDATION_ERROR` rather than ignored.
13+
- `GET /api/v1/auth/admin/authorized-clients?userId=ID` answers another person's, for a platform administrator only. It is gated exactly as the admin session doors are (ADR-0068 D2, a held session refused per ADR-0069 D3). The gate runs before the query is read, so anyone else (an organization owner or admin included) gets one `403 PERMISSION_DENIED` whether or not the person exists. It lists every organization the person granted in. A missing, empty or repeated `userId` is `400`, and an id naming no user is `404 RESOURCE_NOT_FOUND`. It is read only.
14+
15+
Each row carries exactly `id` (the consent id, the key `POST /oauth2/delete-consent` takes), `clientId`, `name`, `scopes`, `organizationId`, `authorizedAt` and `lastUsedAt`. Nothing in it identifies or reconstructs a token. The answer is newest first, at most 200 rows, with `truncated: true` when the person holds more, and it is sent with `Cache-Control: no-store`.
16+
17+
`lastUsedAt` is the start of the most recent clock hour in which the client made a verified MCP call under that consent, or `null` when none has been recorded. The server writes it after `verifyMcpAccessToken` admits a token, and only on the consent that admitted it: the consent for the token's client, person and organization, created no later than the token was issued, the rule the verifier's own consent read applies. So a revoked consent, or a token of an earlier grant, stamps nothing. The write is off the request path, at most once per consent per hour in each process (several processes may each write the same hour start), from the verified token and the server clock alone. A store failure never fails the MCP call, and each distinct failure is logged once per process at `error`; a consent withdrawn between the stamp's read and its write is not a failure. It lands in the new `sys_oauth_consent.last_used_at` column, so a deployment picks it up at its next schema sync. Until then the reads answer `500` and the stamp logs that error once per process.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/mcp': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
fix(mcp, runtime)!: MCP describe_object answers the same field-masked object schema the /meta door answers (ADR-0106)
8+
9+
Clause-②: no (narrowing) — the MCP object-schema reads now serve a caller only the fields the `/meta` object door serves that caller, which ADR-0106 D1 ("a field the caller cannot read does not exist for that caller, on any plane") and the MCP security model ("field-level security applies to the agent exactly as it does to a person") already promise, so no stated contract is narrowed; what narrows is the answer to a caller with a `readable: false` field. Nothing is widened: no export, answer member, error code or status is added.
10+
Metadata-protocol: none
11+
Skills-docs: no move — `skills/**` teaches no field set for `describe_object`; `content/docs/ai/connect-mcp.mdx` states that schema discovery is covered by field-level security.
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema, no export and no stored shape changes: the MCP tools answer a field-restricted caller the field set the metadata door already serves them, with a code and status already on the wire. There is nothing for a migration to rewrite and no tombstone. -->
14+
15+
**BREAKING** for a field-restricted caller of the MCP object-schema reads, on both transports (`/api/v1/mcp` and the stdio server).
16+
17+
- **Now.** `describe_object`, `list_objects`' `fieldCount`, the fields `validate_expression` puts in scope (and its `label()` option-field check) and, on the stdio server, the `objectstack://objects` resources serve each object through the ADR-0106 posture and projection the `/meta` doors run (`resolveObjectSchemaMaskPosture` → `relateObjectSchemaMaskPosture` → `applyObjectSchemaMask`, `@objectstack/metadata-core`), for the same caller: a field the caller cannot read is not listed, and the D4 exemptions, the D6 tiers and the D8 switch (`OS_ALLOW_UNMASKED_OBJECT_METADATA`) are the shared posture's. The HTTP bridge asks the `/meta` door's own masker (`resolveObjectMasker` / `maskObjectSchema`, `@objectstack/runtime`); the stdio bridge asks the host's `security` service about its API-key principal.
18+
- **Refusal.** Where the caller's field visibility cannot be evaluated, or the projection would leave the object with no field, the read is refused as the `/meta` door refuses it: `FIELD_VISIBILITY_UNRESOLVED` / 503, rendered as the tools' coded error (`{ error: { code, message, status } }`) and, on a stdio object resource, as `{ error, code, status }`. One object that cannot be projected refuses `list_objects`, as the `/meta` object list does.
19+
- **Unchanged.** A caller who may read every field, a D4-exempt caller and a deployment with no `security` service are answered byte for byte as before. The data verbs, their gates and `list_actions` / `run_action` are untouched. The stdio object reads now resolve the API-key principal on each call, as the data verbs already do, so a revoked key is refused there too.
20+
- **`@objectstack/mcp`** now depends on `@objectstack/metadata-core` at runtime (it was a dev dependency).
21+
- **`@objectstack/spec`**: the error-code ledger lists `FIELD_VISIBILITY_UNRESOLVED` under `@objectstack/runtime` and `@objectstack/mcp` as well, its two new emitters. The code itself was already registered.

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

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ decision.
245245

246246
## What the agent gets
247247

248-
Twelve tools, generated from your metadata:
248+
Thirteen tools, generated from your metadata:
249249

250250
| Tool | What it does |
251251
|:---|:---|
@@ -254,6 +254,7 @@ Twelve tools, generated from your metadata:
254254
| `query_records` / `get_record` | Read data (list queries are capped at 50 rows per page by default) |
255255
| `aggregate_records` | Grouped aggregation (registered when the active driver supports it) |
256256
| `create_record` / `update_record` / `delete_record` | Write data |
257+
| `delete_records` | Delete many records of one object, by a filter or an id list, at most 200 per call (an OAuth token needs both `data:read` and `data:write`) |
257258
| `list_actions` / `run_action` | Discover and invoke your business actions by name |
258259
| `resume_run` | Finish a flow action that paused on a screen, by submitting the screen's values |
259260

@@ -360,8 +361,12 @@ drive this MCP server. Same word, different layer.
360361
- **Every call runs as the caller.** The MCP bridge resolves the same
361362
`ExecutionContext` as a REST request, so RBAC, row-level security, and
362363
field-level security apply to the agent exactly as they do to a person in
363-
the Console. Sparse results or denied writes usually mean governance is
364-
*working*, not that the connection is broken.
364+
the Console. Schema discovery is included: `describe_object`,
365+
`list_objects`' `fieldCount` and the fields `validate_expression` puts in
366+
scope name only the fields the caller can read, the same field set
367+
`GET /api/v1/meta/object/:name` serves that caller. Sparse results or denied
368+
writes usually mean governance is *working*, not that the connection is
369+
broken.
365370
- **OAuth scopes narrow the toolset.** Tokens carry `data:read`,
366371
`data:write`, and `actions:execute` scopes — tools outside the granted
367372
scopes are not even registered for that session. API-key and session
@@ -383,6 +388,25 @@ drive this MCP server. Same word, different layer.
383388
with `confirm: true`; the flag asserts an approval, it does not obtain one.
384389
REST `DELETE /api/v1/data/:object/:id` and `update_record` take no
385390
confirmation.
391+
- **`delete_records` takes the same gate, and its refusal is the preview.** A
392+
call without `confirm: true` reads the selection as the caller and deletes
393+
nothing: the same `428` refusal (`details.actionName` naming
394+
`delete_records`) arrives with a `preview` beside it, saying how many records
395+
match, how many a confirmed call attempts and a sample of their ids, for the
396+
agent to show its human. A confirmed call deletes at most 200 records, each
397+
exactly as `delete_record` would: under the caller's permissions and
398+
row-level security, and through the object's delete rules (a package's
399+
locking hook, a restricted relation), which no caller bypasses, an
400+
administrator included. Records a rule refuses stay, and the answer counts
401+
them grouped by reason with their ids; when more than 200 match it says how
402+
many remain, and the next call continues. On a deployment whose read page
403+
size or row budget (`OS_DATA_MAX_PAGE_SIZE`, `OS_DATA_QUERY_ROW_BUDGET`) is
404+
under 200, one call runs at that size instead, and the answer's `cap` says
405+
so. A fault (a store error, a crash)
406+
is not a refusal: it stops the call, and the error names the record it
407+
stopped at and how many were already deleted. Because the preview reads ids
408+
and a count, an OAuth token is offered the tool only when it carries both
409+
`data:read` and `data:write`.
386410

387411
## Verify the connection
388412

@@ -411,7 +435,7 @@ call '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":
411435
# → {"result":{"serverInfo":{"name":"objectstack",...},"capabilities":{"tools":{...},"prompts":{}}},...}
412436

413437
call '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | grep -o '"name":"[a-z_]*"'
414-
# → the twelve tools, list_objects … resume_run
438+
# → the thirteen tools, list_objects … resume_run
415439

416440
call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query_records","arguments":{"objectName":"crm_opportunity","limit":3}}}'
417441
# → {"result":{"content":[{"type":"text","text":"{ \"object\": \"crm_opportunity\", \"records\": [ ... ], \"total\": 23, \"hasMore\": true }"}]}}

0 commit comments

Comments
 (0)