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
14 changes: 14 additions & 0 deletions .changeset/action-confirmation-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/spec": minor
---

`action.ai.requiresConfirmation` gets a real contract — a DECLARATION, not yet a live gate: the contract states that an AI-facing call on an action declaring the flag must carry an explicit confirmation and is refused without it, with a refusal that tells the caller how to retry. No door performs that refusal yet; setting the flag still stops nothing until the runtime half lands.

The flag has always read as a safety gate and has only ever filled one field of the MCP `list_actions` summary. Two of the spec's own passages went further and told authors it "actually stops execution" through an HITL approval queue — a queue the open framework path does not have (the server-side queue is an ObjectOS layer over these same actions). This change defines the gate the flag was always claimed to be. It is additive and defines the contract only; the doors adopt it separately.

- **The request member.** `AIActionConfirmation` (`contracts/ai-service.ts`) declares the confirmation as a closed boolean member, and `AI_ACTION_CONFIRMATION_MEMBER` fixes its one spelling so every AI-facing action door and every retrying client read the same constant. It rides at the top level of the action request — deliberately not inside `params`, which is strict by default (`enforceActionParams`, ADR-0104 D2) and would REJECT an undeclared `confirm` outright on any action that declares params, and deliberately not a transport header, which the action door — a plain function handed a request object — cannot carry and the tool schema an agent reads cannot show.
- **Which predicate gates the refusal.** A door refuses when, and only when, the action's author DECLARED `ai.requiresConfirmation: true` and the request does not carry the member as `true`. This is narrower than the predicate behind the `requiresConfirmation` field of a listing, which falls back to a destructiveness heuristic (`mode: 'delete'` / `variant: 'danger'`) when the author declared nothing: that field advises a client to ask, and an author who declared nothing has asked for nothing. Gating the refusal on the heuristic would start refusing calls that work today, on a guess the author never made. The member is accepted on every call and required only on the declared-gated ones, so a client that confirms whenever a listing says `requiresConfirmation: true` is always correct.
- **The refusal.** `ACTION_CONFIRMATION_REQUIRED`, registered in `ERROR_CODE_LEDGER` under `@objectstack/runtime`, answered 428 — the request is valid and merely incomplete, and the identical call with the member set succeeds. `error.details` is `ActionConfirmationRequiredDetails`: the action name, its object, and the exact member to set, so an agent builds the retry mechanically instead of re-parsing prose. It is not a re-spelling of the standard catalog's `PRECONDITION_REQUIRED`, which leaves a caller unable to tell a confirmation gate from a missing conditional header.
- **Two passages corrected.** The `tool.requiresConfirmation` retirement guidance and the ADR-0049 semantic migration entry no longer describe an approval queue. They state what the contract DECLARES — a gate, nothing parked, nothing held for an operator to find later — and say plainly that the door which performs the refusal has not landed, so the flag does not stop an unconfirmed call today. They also no longer tell an author to prove the gate by invoking the operation without the confirmation member: until that door ships, such a call is not refused, it RUNS.

`list_actions` is unchanged and keeps reporting the flag exactly as it does today.
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +297 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +298 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
| **message** | `string` | ✅ | Readable error message |
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
Expand Down Expand Up @@ -89,6 +89,7 @@ const result = ApiErrorSchema.parse(data);
* `INTEGRATION_ERROR`
* `WEBHOOK_DELIVERY_FAILED`
* `ACCOUNT_LOCKED`
* `ACTION_CONFIRMATION_REQUIRED`
* `ACTION_DISABLED`
* `ALREADY_REVERTED`
* `AMBIGUOUS_MATCH`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ const result = ErrorCode.parse(data);
* `INTEGRATION_ERROR`
* `WEBHOOK_DELIVERY_FAILED`
* `ACCOUNT_LOCKED`
* `ACTION_CONFIRMATION_REQUIRED`
* `ACTION_DISABLED`
* `ALREADY_REVERTED`
* `AMBIGUOUS_MATCH`
Expand Down
4 changes: 2 additions & 2 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -544,9 +544,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte
- **`storage-service-list-retired`** — `contracts.IStorageService.list` → track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781
- Why not automatic: `list(prefix)` was an OPTIONAL contract method documented as "List files in a directory/prefix", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266).
- Done when: No code calls `storage.list(...)` on the `file-storage` service or on any `IStorageService` value. Code that needed "which files are under this prefix" reads the records it wrote — `sys_file` / file-reference rows carry the storage key and page deterministically through ObjectQL — rather than asking the bucket, which is also the only form that stays correct past 1000 objects and across both adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra method is not an error on a class) and is simply unreachable through the contract, so deleting it is cleanup that can follow. The break is on the CALLER side: `storage.list(...)` no longer type-checks, and a PROXY typed against `IStorageService` that forwards to `inner.list` is exactly such a caller — the one in `@objectstack/service-storage` goes with the adapters (#5541). ⚠️ AMENDED 2026-08-09 (#6781, maintainer ruling on cloud#1203, option B): the RESERVED route in the paragraph above was taken. `list` exists again on the contract, cursor-shaped — `list(prefix, { cursor, limit })` returning `{ items, nextCursor }` — because cloud had two first-party callers this repo could not see when the measurement said "nothing calls it" (tenant attachment reclamation, marketplace snapshot GC). This does NOT un-retire anything and the acceptance criterion above is unchanged for what it actually governs: the single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written against it still fails to compile, and the two dialects it had are now pinned against each other in `storage-adapter-list.conformance.test.ts` rather than left to diverge. What changed for an upgrader is only the destination: prefer the records you wrote, and reach for the restored member when there are none.
- **`tool-requires-confirmation-retired`** — `ai.tool.requiresConfirmation` → put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — that is the flag the HITL approval queue actually reads, and the only path that stops execution
- **`tool-requires-confirmation-retired`** — `ai.tool.requiresConfirmation` → put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the flag the platform confirmation CONTRACT is written against. That contract DECLARES that an AI-facing call on an action declaring the flag must carry an explicit confirmation member on the request and is to be refused without it with `ACTION_CONFIRMATION_REQUIRED`, the refusal naming the action and the exact member to set. A gate, not a queue: nothing is parked. ⚠ The refusal is DECLARED, not yet performed — the runtime door lands in #15942, so until then the flag stops nothing on its own and the human in the loop is still yours to arrange
- Why not automatic: `ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087, #3715 (backfilled #6350).
- Done when: No tool definition carries `requiresConfirmation`; the key now raises a located parse error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The load-bearing half is what happens NEXT, and no gate can check it for you: for every tool that carried the flag, decide whether that operation genuinely needs a human in the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: true` and prove the pause exists — invoke it and observe the approval queue hold it, rather than assuming the declaration. If it does not, delete the key knowingly. Deleting it without that decision leaves exactly the state the retirement exists to end: a destructive tool nobody is approving, now without even the false flag to show that somebody once meant to.
- Done when: No tool definition carries `requiresConfirmation`; the key now raises a located parse error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The load-bearing half is what happens NEXT, and no gate can check it for you: for every tool that carried the flag, decide whether that operation genuinely needs a human in the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: true`, which is what the confirmation contract (#16293) gates on. ⛔ Do NOT try to "prove the gate" by invoking the operation without the confirmation member: the runtime door that refuses lands in #15942, so before that ships the call is not refused, it RUNS the destructive operation. Until then the declaration is a contract and the human in the loop is still yours to arrange — which is the decision this criterion is asking you to make, not a test to run. If the operation does not need a human, delete the key knowingly. Deleting it without that decision leaves exactly the state the retirement exists to end: a destructive tool nobody is approving, now without even the false flag to show that somebody once meant to.
- **`ui-interaction-config-family-retired`** — `ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)` → (removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)
- Why not automatic: Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988.
- Done when: No code imports any of the 64 retired names from `@objectstack/spec` or `@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, `SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, `LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, `TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, `AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, `MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, `DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, `FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, `KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, `ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, `EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these blocks: a stack that parsed before parses byte-for-byte the same after. If you consumed the bare `ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, declare that union locally — it is your client's policy, not the platform's. `@objectstack/spec/integration`'s `ConnectorConflictResolution` (connector sync) and `@objectstack/spec/api`'s `ConflictResolutionStrategy` (route merge policy) are different concepts and are untouched.
Expand Down
Loading
Loading