@ultimat3/cli 29.3.2 → 30.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +64 -40
- package/README.md +61 -11
- package/package.json +42 -28
- package/src/affected-tests.ts +97 -0
- package/src/affected-unowned.ts +43 -0
- package/src/affected.ts +25 -6
- package/src/app-boundaries.ts +29 -1
- package/src/app-evals.ts +12 -4
- package/src/app-load-unresolved.ts +62 -0
- package/src/app-load.ts +30 -37
- package/src/app-mcp.ts +14 -2
- package/src/app-module-graph.ts +232 -0
- package/src/app-reload-graph.ts +2 -2
- package/src/app-root.ts +37 -1
- package/src/bin.ts +1 -0
- package/src/boundary-findings.ts +6 -0
- package/src/budgets-freshness.ts +32 -0
- package/src/budgets.ts +20 -1
- package/src/build-capture-env.ts +9 -0
- package/src/cdp-shot-a11y.ts +2 -39
- package/src/ci-tested.ts +243 -0
- package/src/client-emit-customer-runtime.ts +139 -0
- package/src/client-emit-customer.ts +108 -0
- package/src/client-emit-drift.ts +72 -0
- package/src/client-emit-header.ts +67 -0
- package/src/client-emit-schemas.ts +175 -0
- package/src/client-emit-types.ts +121 -0
- package/src/client-emit-vendor.ts +146 -0
- package/src/client-errors-routes.ts +229 -0
- package/src/client-errors-script.ts +74 -0
- package/src/client-vitals-route.ts +197 -0
- package/src/client-vitals-script.ts +56 -0
- package/src/cmd-affected.ts +23 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +35 -9
- package/src/cmd-ci-spec.ts +42 -6
- package/src/cmd-ci.ts +99 -2
- package/src/cmd-client-customer.ts +134 -0
- package/src/cmd-client-source.ts +81 -0
- package/src/cmd-client-spec.ts +48 -0
- package/src/cmd-client-write.ts +41 -0
- package/src/cmd-client.ts +111 -0
- package/src/cmd-db-recount.ts +120 -0
- package/src/cmd-db-spec.ts +36 -6
- package/src/cmd-db.ts +13 -3
- package/src/cmd-deploy-compose.ts +61 -3
- package/src/cmd-deploy-drain.ts +10 -2
- package/src/cmd-deploy-helm.ts +11 -0
- package/src/cmd-deploy-preflight.ts +118 -0
- package/src/cmd-deploy-spec.ts +58 -6
- package/src/cmd-deploy-verify.ts +380 -0
- package/src/cmd-deploy.ts +42 -9
- package/src/cmd-docs.ts +34 -4
- package/src/cmd-doctor.ts +66 -9
- package/src/cmd-drill-spec.ts +25 -0
- package/src/cmd-drill.ts +176 -0
- package/src/cmd-env-init.ts +102 -0
- package/src/cmd-env-spec.ts +10 -3
- package/src/cmd-env.ts +15 -10
- package/src/cmd-errors-recent.ts +60 -0
- package/src/cmd-errors-spec.ts +9 -1
- package/src/cmd-errors.ts +4 -0
- package/src/cmd-exercise-spec.ts +19 -0
- package/src/cmd-exercise.ts +129 -0
- package/src/cmd-generate-spec.ts +9 -1
- package/src/cmd-generate.ts +32 -27
- package/src/cmd-graph-load.ts +97 -0
- package/src/cmd-graph-spec.ts +16 -0
- package/src/cmd-graph.ts +73 -0
- package/src/cmd-i18n.ts +4 -1
- package/src/cmd-impact-spec.ts +13 -0
- package/src/cmd-impact.ts +64 -0
- package/src/cmd-jobs-resolve.ts +76 -0
- package/src/cmd-jobs-spec.ts +21 -2
- package/src/cmd-jobs.ts +10 -6
- package/src/cmd-mail-spec.ts +28 -0
- package/src/cmd-mail.ts +70 -0
- package/src/cmd-mcp-lock.ts +69 -0
- package/src/cmd-mcp-spec.ts +23 -4
- package/src/cmd-mcp.ts +17 -3
- package/src/cmd-new-spec.ts +8 -1
- package/src/cmd-new.ts +61 -9
- package/src/cmd-planned.ts +0 -6
- package/src/cmd-repro-spec.ts +31 -0
- package/src/cmd-repro.ts +174 -0
- package/src/cmd-stop-spec.ts +21 -0
- package/src/cmd-stop.ts +166 -0
- package/src/cmd-test-spec.ts +17 -4
- package/src/cmd-test.ts +70 -14
- package/src/cmd-upgrade-spec.ts +26 -0
- package/src/cmd-upgrade.ts +274 -0
- package/src/cmd-verify-spec.ts +3 -3
- package/src/db-backfill.ts +6 -0
- package/src/db-diagnostics.ts +106 -0
- package/src/db-generate.ts +28 -8
- package/src/db-reset-template.ts +1 -1
- package/src/db-rolling.ts +45 -0
- package/src/db-seed-generate.ts +188 -0
- package/src/deploy-verify-findings.ts +98 -0
- package/src/deploy-verify-flags.ts +109 -0
- package/src/deploy-verify-grade.ts +185 -0
- package/src/deploy-verify-queues.ts +53 -0
- package/src/dev-boot.ts +35 -15
- package/src/dev-child-watch.ts +16 -4
- package/src/dev-dashboard-guard.ts +8 -1
- package/src/dev-dashboard.ts +7 -0
- package/src/dev-lock-identity.ts +61 -0
- package/src/dev-lock-port-holder.ts +80 -0
- package/src/dev-lock-steal.ts +124 -0
- package/src/dev-lock.ts +92 -120
- package/src/dev-mail-templates.ts +51 -0
- package/src/dev-rebuild.ts +26 -2
- package/src/dev-route-table.ts +17 -0
- package/src/dev-supervisor.ts +5 -2
- package/src/dev-watch-tree.ts +11 -10
- package/src/dev-watch.ts +1 -1
- package/src/dispatch.ts +29 -6
- package/src/docs-section.ts +69 -0
- package/src/docs-sources.ts +100 -0
- package/src/doctor-auth.ts +11 -2
- package/src/doctor-chart-alerts.ts +55 -0
- package/src/doctor-pooler.ts +107 -0
- package/src/doctor-rolling.ts +35 -0
- package/src/doctor-row-security.ts +40 -0
- package/src/document-options.ts +13 -0
- package/src/drill-sync-lane.ts +25 -0
- package/src/drill-targets.ts +39 -0
- package/src/duplicate-packages.ts +1 -1
- package/src/env-production.ts +93 -0
- package/src/error-codes-shot.ts +59 -0
- package/src/error-codes-tooling.ts +29 -0
- package/src/error-codes.ts +100 -54
- package/src/error-contract.ts +2 -2
- package/src/errors.ts +22 -3
- package/src/exercise-database.ts +99 -0
- package/src/exercise-findings.ts +159 -0
- package/src/exercise-overrides.ts +42 -0
- package/src/exercise-registration.ts +58 -0
- package/src/exercise-static.ts +88 -0
- package/src/exercise-subject.ts +69 -0
- package/src/failure-capture-boot.ts +30 -0
- package/src/framework-env.ts +7 -1
- package/src/framework-schema.ts +72 -5
- package/src/generate-catalog-locales.ts +19 -0
- package/src/generate-chart-alerts.ts +156 -0
- package/src/generate-feature-required.ts +65 -0
- package/src/generate-feature.ts +83 -6
- package/src/generate-files.ts +24 -0
- package/src/generate-format-written.ts +32 -0
- package/src/generate-kinds.ts +6 -0
- package/src/generate-sign-in-config.ts +100 -0
- package/src/generate-sign-in-manifest.ts +28 -0
- package/src/generate-sign-in-mode.ts +101 -0
- package/src/generate-sign-in.ts +250 -0
- package/src/generate-write.ts +44 -7
- package/src/hand-rolled-socket.ts +72 -0
- package/src/i18n-app-key-shapes.ts +29 -0
- package/src/i18n-audit.ts +10 -3
- package/src/i18n-registration.ts +18 -3
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +4 -3
- package/src/island-disposer.ts +116 -0
- package/src/island-realtime-state.ts +34 -6
- package/src/island-realtime.ts +6 -2
- package/src/island-routes.ts +3 -2
- package/src/isr-query-advice.ts +6 -11
- package/src/isr-query-guard.ts +4 -3
- package/src/job-budget.ts +95 -0
- package/src/jobs-json.ts +11 -0
- package/src/lint-findings.ts +104 -0
- package/src/local-cli.ts +34 -4
- package/src/loopback-host.ts +2 -2
- package/src/mail-preview-findings.ts +24 -0
- package/src/mcp-errors.ts +82 -2
- package/src/mcp-host.ts +11 -1
- package/src/mcp-lock-findings.ts +57 -0
- package/src/measure-paths.ts +12 -4
- package/src/measure-scope.ts +27 -13
- package/src/messages-client.ts +23 -0
- package/src/messages-db.ts +56 -0
- package/src/messages-gate.ts +107 -0
- package/src/messages-repro.ts +11 -0
- package/src/messages-shot.ts +51 -0
- package/src/messages-upgrade.ts +45 -0
- package/src/messages.ts +53 -178
- package/src/metrics-endpoint.ts +23 -5
- package/src/new-shapes-generate.ts +71 -0
- package/src/new-shapes.ts +63 -0
- package/src/openapi-compile-ir.ts +68 -0
- package/src/openapi-compile-schema.ts +284 -0
- package/src/openapi-compile.ts +273 -0
- package/src/openapi-refuse.ts +45 -0
- package/src/otlp-export.ts +2 -1
- package/src/output-findings.ts +123 -0
- package/src/output.ts +32 -8
- package/src/package-boundaries.ts +110 -0
- package/src/page-pins.ts +23 -0
- package/src/plain-routes.ts +13 -0
- package/src/prerender-entries.ts +59 -0
- package/src/prerender-heaviest.ts +27 -0
- package/src/prerender-preload.ts +6 -0
- package/src/prerender-report.ts +12 -0
- package/src/prerender-seo-reads.ts +38 -0
- package/src/prerender-served.ts +22 -7
- package/src/prerender-vitals.ts +42 -0
- package/src/prerender.ts +61 -32
- package/src/pwa-artifacts.ts +4 -3
- package/src/pwa-manifest-locales.ts +1 -1
- package/src/registry.ts +18 -0
- package/src/repro-bundle.ts +65 -0
- package/src/repro-errors.ts +47 -0
- package/src/repro-job.ts +72 -0
- package/src/repro-literal.ts +41 -0
- package/src/repro-request.ts +48 -0
- package/src/repro-write.ts +100 -0
- package/src/retain-sweep.ts +27 -0
- package/src/retained-assets-boot.ts +113 -0
- package/src/retained-assets-layout.ts +110 -0
- package/src/retained-assets-read.ts +142 -0
- package/src/retained-assets-sweep.ts +70 -0
- package/src/retained-assets.ts +171 -0
- package/src/revalidated-response.ts +8 -5
- package/src/role-replicator.ts +7 -1
- package/src/role-start-devices.ts +119 -0
- package/src/role-start-types.ts +6 -0
- package/src/role-start.ts +22 -5
- package/src/role-sync.ts +7 -1
- package/src/route-islands.ts +42 -0
- package/src/route-table.ts +2 -1
- package/src/row-security-boot.ts +58 -0
- package/src/row-security-wanted.ts +32 -0
- package/src/runtime-admin.ts +0 -1
- package/src/runtime-assets-site-cache.ts +55 -0
- package/src/runtime-assets-site.ts +136 -0
- package/src/runtime-assets.ts +99 -55
- package/src/runtime-bindings.ts +19 -3
- package/src/runtime-cache.ts +23 -14
- package/src/runtime-isr-outcome.ts +114 -60
- package/src/runtime-purge.ts +14 -2
- package/src/runtime-queue.ts +31 -4
- package/src/runtime-render-document.ts +204 -0
- package/src/runtime-render.ts +48 -198
- package/src/runtime-replica-position.ts +62 -0
- package/src/runtime-replica.ts +18 -3
- package/src/runtime-s3-path-style.ts +17 -0
- package/src/runtime-services.ts +19 -6
- package/src/runtime-statement-bound.ts +22 -0
- package/src/runtime-storage-upload.ts +9 -0
- package/src/runtime-storage.ts +42 -138
- package/src/schema-diff-views.ts +54 -0
- package/src/schema-diff.ts +21 -3
- package/src/schema-drift.ts +28 -9
- package/src/schema-dump-drift.ts +91 -7
- package/src/script-csp.ts +1 -1
- package/src/seo-cards.ts +26 -0
- package/src/seo-routes.ts +22 -8
- package/src/serve-boot.ts +54 -21
- package/src/serve-drain.ts +1 -0
- package/src/serve-env.ts +26 -8
- package/src/serve-retire.ts +12 -6
- package/src/serve-web.ts +42 -4
- package/src/serve.ts +9 -7
- package/src/site-asset-routes.ts +17 -27
- package/src/site-config.ts +4 -0
- package/src/site-llms-pages.ts +106 -0
- package/src/site-llms.ts +64 -0
- package/src/site-seo-write.ts +4 -2
- package/src/site-seo.ts +55 -6
- package/src/source-mtime.ts +50 -0
- package/src/static-capture-errors.ts +41 -0
- package/src/static-capture.ts +56 -0
- package/src/static-cards-serve.ts +39 -0
- package/src/static-cards.ts +93 -0
- package/src/static-document.ts +14 -13
- package/src/static-images.ts +115 -0
- package/src/stream-hole-scope.ts +55 -0
- package/src/style-bundle.ts +1 -1
- package/src/style-routes.ts +3 -2
- package/src/sw-precache-plan.ts +2 -2
- package/src/sw-routes.ts +2 -2
- package/src/templates/action-tests.ts +306 -0
- package/src/templates/action.ts +81 -269
- package/src/templates/backfill.ts +1 -1
- package/src/templates/entity.ts +21 -5
- package/src/templates/github/ci.yml.ts +8 -9
- package/src/templates/guard-bare-error.ts +2 -2
- package/src/templates/guard-error-declared.ts +263 -0
- package/src/templates/guard-native-confirm.ts +132 -0
- package/src/templates/guard-raw-fetch.ts +151 -0
- package/src/templates/guard-raw-interval.ts +121 -0
- package/src/templates/guard-repo-raw-sql.ts +71 -2
- package/src/templates/job.ts +1 -1
- package/src/templates/naming.ts +5 -0
- package/src/templates/pinned/docker-compose.yml +6 -0
- package/src/templates/pinned-images.ts +8 -0
- package/src/templates/policy.ts +85 -2
- package/src/templates/query.ts +70 -13
- package/src/templates/resource-create.ts +3 -1
- package/src/templates/resource-form-island.ts +15 -4
- package/src/templates/resource-page-db.ts +83 -0
- package/src/templates/resource-page.ts +161 -52
- package/src/templates/route.ts +4 -6
- package/src/templates/scaffold-app.ts +3 -0
- package/src/templates/scaffold-auth.ts +2 -1
- package/src/templates/scaffold-bin.ts +5 -52
- package/src/templates/scaffold-claude-agents.ts +19 -19
- package/src/templates/scaffold-claude-commands.ts +28 -31
- package/src/templates/scaffold-claude.ts +26 -23
- package/src/templates/scaffold-container-compose.ts +6 -1
- package/src/templates/scaffold-container.ts +24 -4
- package/src/templates/scaffold-dashboard-bare.ts +3 -3
- package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
- package/src/templates/scaffold-dashboard-example-view.ts +156 -0
- package/src/templates/scaffold-dashboard-example.ts +43 -131
- package/src/templates/scaffold-dashboard-shared.ts +3 -7
- package/src/templates/scaffold-db-client.ts +20 -6
- package/src/templates/scaffold-db-package.ts +5 -14
- package/src/templates/scaffold-docs.ts +73 -57
- package/src/templates/scaffold-domain-package.ts +18 -14
- package/src/templates/scaffold-entries.ts +7 -2
- package/src/templates/scaffold-exercise.ts +37 -0
- package/src/templates/scaffold-guards.ts +13 -1
- package/src/templates/scaffold-helm-alerts.ts +234 -0
- package/src/templates/scaffold-helm-templates.ts +7 -5
- package/src/templates/scaffold-helm.ts +46 -12
- package/src/templates/scaffold-i18n.ts +6 -2
- package/src/templates/scaffold-mcp-endpoint.ts +182 -0
- package/src/templates/scaffold-mcp-json.ts +31 -0
- package/src/templates/scaffold-mcp-package.ts +59 -56
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +45 -79
- package/src/templates/scaffold-test-services.ts +13 -5
- package/src/templates/shapes/api-keys.ts +170 -0
- package/src/templates/shapes/api.ts +101 -0
- package/src/templates/shapes/blog-site.ts +340 -0
- package/src/templates/shapes/blog-slice.ts +265 -0
- package/src/templates/shapes/blog.ts +114 -0
- package/src/templates/shapes/dashboard-detail.ts +242 -0
- package/src/templates/shapes/dashboard.ts +53 -0
- package/src/templates/shapes/index.ts +17 -0
- package/src/templates/shapes/overlay.ts +99 -0
- package/src/templates/shapes/shared-edits.ts +46 -0
- package/src/templates/sign-in-action-tests.ts +237 -0
- package/src/templates/sign-in-authenticator.ts +112 -0
- package/src/templates/sign-in-oidc.ts +189 -0
- package/src/templates/sign-in-pages.ts +148 -0
- package/src/templates/sign-in-password.ts +216 -0
- package/src/templates/sign-in-shared.ts +268 -0
- package/src/templates/slice-foundation.ts +70 -15
- package/src/test-batches.ts +1 -1
- package/src/test-database.ts +1 -1
- package/src/test-shards.ts +7 -1
- package/src/test-workers.ts +18 -12
- package/src/tool-gate-names.ts +34 -0
- package/src/upgrade-agents-link.ts +49 -0
- package/src/upgrade-bun-floor.ts +225 -0
- package/src/upgrade-gate.ts +228 -0
- package/src/upgrade-pins.ts +63 -0
- package/src/upgrade-plan.ts +214 -0
- package/src/upgrade-registry.ts +53 -0
- package/src/upgrade-report.ts +139 -0
- package/src/upgrade-root.ts +74 -0
- package/src/verify-budgets-build.ts +118 -0
- package/src/verify-checks.ts +67 -24
- package/src/verify-coverage-run.ts +1 -1
- package/src/verify-e2e-lane.ts +41 -0
- package/src/verify-exercise.ts +64 -0
- package/src/verify-floor.ts +41 -0
- package/src/verify-merge.ts +19 -4
- package/src/verify-role-load.ts +2 -0
- package/src/verify-run.ts +28 -8
- package/src/verify-shard.ts +12 -2
- package/src/verify-skip-ceiling.ts +87 -0
- package/src/verify-step.ts +5 -0
- package/src/verify-tests.ts +25 -5
- package/src/view-drift.ts +55 -0
- package/src/worker-bundle.ts +3 -2
- package/src/workspace-graph.ts +38 -19
- package/src/write-generated.ts +43 -0
- package/src/zone-boot.ts +20 -0
- package/wiki/Actions.md +342 -0
- package/wiki/Admin-Dashboard.md +432 -0
- package/wiki/Agents.md +958 -0
- package/wiki/Auth.md +195 -0
- package/wiki/Bare-VM.md +111 -0
- package/wiki/Batching-And-Preloading.md +136 -0
- package/wiki/Building-Your-Own-Base.md +203 -0
- package/wiki/CI-Parallel-Gate.md +220 -0
- package/wiki/CLI-Reference.md +1894 -0
- package/wiki/Caching-And-Invalidation.md +255 -0
- package/wiki/Client-Data.md +194 -0
- package/wiki/Client-Navigation.md +513 -0
- package/wiki/Compared.md +93 -0
- package/wiki/Configuration.md +868 -0
- package/wiki/Contributing.md +12 -0
- package/wiki/Deploy-Recipes.md +122 -0
- package/wiki/Deployment.md +508 -0
- package/wiki/Entities-And-Migrations.md +1116 -0
- package/wiki/FAQ.md +175 -0
- package/wiki/Feature-Flags.md +65 -0
- package/wiki/Getting-Started.md +221 -0
- package/wiki/Home.md +160 -0
- package/wiki/I18n.md +239 -0
- package/wiki/Installation.md +206 -0
- package/wiki/Interface-Rules.md +339 -0
- package/wiki/Jobs-And-Workflows.md +783 -0
- package/wiki/Known-Gaps.md +185 -0
- package/wiki/MCP-And-AI.md +753 -0
- package/wiki/Mail.md +138 -0
- package/wiki/Migrating-An-Existing-App.md +594 -0
- package/wiki/Migrating-From-Django.md +54 -0
- package/wiki/Migrating-From-Laravel.md +53 -0
- package/wiki/Migrating-From-Node-Drizzle.md +53 -0
- package/wiki/Migrating-From-Rails.md +53 -0
- package/wiki/Migrations-And-Backfills.md +552 -0
- package/wiki/Money.md +338 -0
- package/wiki/N-Plus-One-Detection.md +197 -0
- package/wiki/Notify.md +208 -0
- package/wiki/Observability.md +245 -0
- package/wiki/Outbound-HTTP.md +109 -0
- package/wiki/PWA-And-Offline.md +343 -0
- package/wiki/Policies-And-Authz.md +251 -0
- package/wiki/Project-Layout.md +150 -0
- package/wiki/Queries-And-Live-Queries.md +429 -0
- package/wiki/Realtime.md +734 -0
- package/wiki/Resource-Management.md +121 -0
- package/wiki/Routes-And-Render-Modes.md +521 -0
- package/wiki/SEO.md +288 -0
- package/wiki/Scheduled-Tasks.md +175 -0
- package/wiki/Scraping.md +493 -0
- package/wiki/Shapes.md +63 -0
- package/wiki/Stability.md +97 -0
- package/wiki/Static-Assets.md +166 -0
- package/wiki/Storage-And-Uploads.md +404 -0
- package/wiki/Testing.md +1313 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
package/wiki/Actions.md
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# Actions
|
|
2
|
+
|
|
3
|
+
The load-bearing primitive. A mutation or command, server-authoritative, declared once.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
export const publishPost = action({
|
|
7
|
+
input: t.object({ postId: t.uuid, notify: t.boolean.default(true) }),
|
|
8
|
+
output: PostView,
|
|
9
|
+
policy: can('post:publish', ({ input, actor }) => ownsPost(actor, input.postId)),
|
|
10
|
+
cache: { invalidates: [tag.post, tag.feed] },
|
|
11
|
+
mcp: { expose: true, description: 'Publish a draft post' },
|
|
12
|
+
async handle({ input, ctx }) {
|
|
13
|
+
const post = await ctx.posts.publish(input.postId);
|
|
14
|
+
if (input.notify) await notifySubscribers.enqueue({ postId: post.id, orgId: post.orgId });
|
|
15
|
+
return post;
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Declared one per file in a feature's `actions/` (`actions/publish-post.ts`, what `x g` writes). Named export, never default. The export name is the identity, and it must be globally unique. Only the HTTP path is *derived* from it (`publishPost` → `POST /api/posts/publish`); the OpenAPI `operationId` and the **MCP tool name are the export name verbatim** — `publishPost`, never `publish_post`. `As of 2026-08` that holds on every surface at once: what `tools/call` accepts, what `defineAppMcp`'s `scopes:` map is keyed on, what the LLM tool list offers, what `openapi.json`'s `x-ultimate.mcpTool` publishes, and what `describe().mcp.tool` reports.
|
|
21
|
+
|
|
22
|
+
## Fields
|
|
23
|
+
|
|
24
|
+
| Field | Type | Required | Meaning |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| `input` | Standard Schema (`t` from `@ultimat3/schema`) | yes | parsed before anything else runs; drives the TS type, JSON Schema, OpenAPI request body, MCP tool schema. `t` is the shipped dependency-free builtin provider; ArkType, Zod and Valibot are optional swaps behind `configureSchemaProvider` and ship no adapter |
|
|
27
|
+
| `output` | Standard Schema | yes | the response contract; drives the typed client return type and the OpenAPI response. A `.sensitive()` field reachable from it is `X_SCHEMA_SENSITIVE_OUTPUT` at registration: an output is sent back |
|
|
28
|
+
| `policy` | `Policy` from `can(...)` | yes | the one authz decision, evaluated on every surface. Omitting it is a build error |
|
|
29
|
+
| `cache.invalidates` | `readonly CacheTag[]` | no | tags dropped from every cache tier after `handle` settles — and, for an action invoked inside `withTransaction`, only once the root transaction **commits** (never on rollback). Unknown tag = compile error |
|
|
30
|
+
| `mcp.expose` | `boolean` | no (opt-in) | only a literal `true` makes the action a tool; silence exposes nothing. One predicate answers it everywhere — the tool, the LLM tool list, `describe().mcp.expose`, and `openapi.json`'s `x-ultimate.mcpTool` (which is `null` when there is no tool). Listing an un-exposed action in `defineAppMcp` is `X_MCP_TOOL_UNDECLARED` at boot |
|
|
31
|
+
| `mcp.description` | `string` | no | the tool description an agent reads, and the OpenAPI `summary`. Contract text, so it stays outside `t()` — `openapi.json` must not depend on a locale. Write it for a stranger |
|
|
32
|
+
| `mcp.visibleTo` | `readonly string[]` | no | roles that may see the projected tool; a caller whose role is not named gets ToolNotFound, never Forbidden — the policy still decides every call |
|
|
33
|
+
| `rateLimit` | `{ limit: number \| (({ ctx }) => number \| Promise<number>); windowMs: number }` | no | `limit` may be resolved **per call** from `ctx` (the tenant's plan, read by the app), before any bucket is touched; a resolver answering `NaN`, `0`, `Infinity` or a non-number is `X_RATE_LIMIT_INVALID`, never allow-all. A per-call limit publishes as `rateLimit: null` + `rateLimitPerCall: { windowMs }` in `describe()` and `x-ultimate.rateLimit.limit: 'per-call'` in `openapi.json`. Spent **inside `invoke`**, so the HTTP route, the MCP tool, the in-app agent's tool call and `.job()` draw on ONE bucket — the third call to a `limit: 2` action is `X_RATE_LIMITED` whichever surface it came through; a direct in-process call spends nothing. Keyed actor → org → IP; counted in `@ultimat3/http`'s installed store (`installRateLimitStore()`, filled by the boot on every role with the same instance `httpServer({ rateLimitStore })` holds). Over HTTP the refusal is `429` + `Retry-After` and `RateLimit-*`, and the route is not also capped by the `default` bucket; the same `toBucket` conversion feeds `openapi.json`, so the published numbers are the enforced ones. `/mcp` additionally buckets by verb class (`MCP_RATE_LIMITS`, read 120/min, write 20/min per token) |
|
|
34
|
+
| `meter` | `{ name, period: 'hour' \| 'day' \| 'month', limit, warnAt?, onWarn? }` | no | a durable per-period allowance — see [Usage meters](#usage-meters-meter). Drawn on **every** surface, `server` included, after the policy and before `handle`; spent, `X_METER_EXCEEDED` (429) |
|
|
35
|
+
| `idempotent` | `boolean` | no — **required `true` on a `mutator`** | marks the action safe to retry with an `Idempotency-Key` header — over MCP, the tool's optional `idempotencyKey` argument. Inside a transaction the Postgres store settles the record with the write's own `COMMIT`, so a rollback never leaves a `settled` record behind. A field core redacts by name (`apiKey`, `password`, `*Token`…) or a `Secret` is stored as `[redacted]`, and the replay of such an answer is `X_IDEMPOTENT_REPLAY_REDACTED` |
|
|
36
|
+
| `http.path` | `string` | no | pins the URL whatever the app's `pathStyle` derives — for a path a caller already holds (`'/api/uploads/signed'`) or a resource-shaped one (`'/orders/:id/refund'`). Lowercase static segments and `:param`s, never `/_x`; each `:param` a REQUIRED `t.string` key of `input:`, else `X_ACTION_HTTP_PATH_INVALID` at registration. The URL's value joins the body before the input is parsed, so the schema, the policy, `row` and the handler read one object; a body naming the key with another value is **400** `X_HTTP_PATH_INPUT_CONFLICT`, never overridden. Under a bearer mount the pin is served at the prefix (`/v1/orders/:id/refund`) — and still at its own path, with the cookie session, as every pin is; the policy decides both. `derivePath(name)` refuses a template; `actionHttpPath(name, { id })` fills it. A `deprecated.replacedBy` naming one is `X_ACTION_DEPRECATION_INVALID`. An inbound webhook is not this door: it is `webhookReceiver()` ([Jobs and workflows](Jobs-And-Workflows#inbound-webhooks)) |
|
|
37
|
+
| `http.method` | `'POST'` · `'PUT'` · `'PATCH'` · `'DELETE'` | no — `'POST'` | the verb the pin answers; needs `http.path`. `X_ACTION_PATH_DUPLICATE` keys on method and path, so `PUT /orders/:id` and `DELETE /orders/:id` are two actions. `action.client()` sends it and fills the URL from the input (each value one encoded segment, the key left out of the body); `rpc()` by name cannot see a pin. In `openapi.json`: `{id}`, an `in: path` required parameter, the key gone from the request body, the operation under the declared verb. `x.manifest.json` writes `http: { path, method }` for a pin only; moving it is breaking. `As of 2026-11` |
|
|
38
|
+
| `errors` | `readonly string[]` | no — `x g action` writes `errors: []` | the refusals a caller may branch on, each a registered 4xx code listed once (`X_ACTION_ERRORS_INVALID` otherwise, at `action()`). Declared, the list is CLOSED — see [Declared refusals](#declared-refusals-errors). Absent: today's open contract, bytes unchanged. `As of 2026-11` |
|
|
39
|
+
| `handle({ input, ctx })` | `(args) => Promise<Output>` | yes | the body. Parsed `input`, ambient `ctx`. Returns `output`-shaped data |
|
|
40
|
+
|
|
41
|
+
**`.sensitive()` on an input field** — `cardPan: t.string.sensitive()` — means sent in, never
|
|
42
|
+
written down by the framework, never sent back (JSON Schema `writeOnly`). Reach, exactly: the
|
|
43
|
+
`audit: true` record's `input` and `x jobs show` hold `[redacted]`, read from THIS action's own
|
|
44
|
+
schema, so another action's `cardPan` and the idempotency store are untouched. Not your own
|
|
45
|
+
`logger.info('x', input)`: that line holds no schema — use `redactKeys` or a `Secret` there.
|
|
46
|
+
A refusal's issue carries `rule` (`string.min`, `enum`, … — `SCHEMA_RULES`) and `params`
|
|
47
|
+
(`{ min: 3 }`) on the wire, so a form translates on the rule, never the English message.
|
|
48
|
+
|
|
49
|
+
`ctx` members an action may use:
|
|
50
|
+
|
|
51
|
+
| Member | Type | Use |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `ctx.actor` | `Actor` | `{ kind: 'user' \| 'service' \| 'agent' \| 'anonymous', id, orgId?, roles, scopes }`. Read-only; authz already ran |
|
|
54
|
+
| `ctx.<service>` | app-augmented | repos and services (`ctx.posts`, `ctx.orgs`, `ctx.mail`) — declared via `CtxServices`, registered with `defineService`. On an HTTP request each is built **lazily, on first read, for the authenticated actor**, and rebuilt if the actor, locale or time zone changes (`packages/http/src/request-services.ts`). From 13.0.0 until 21.0.0 they were built before the `auth` stage and acted as the anonymous actor |
|
|
55
|
+
| `ctx.jobs` | job client | the facade `<job>.enqueue(input)` resolves; enqueue is transactional via the outbox |
|
|
56
|
+
| `ctx.requestId` / `ctx.traceId` | `string` | W3C trace id; the same value crosses HTTP → job → live query |
|
|
57
|
+
| `ctx.locale` / `ctx.tz` | `string` | BCP-47 and IANA. Never format a date without `ctx.tz` |
|
|
58
|
+
| `ctx.clock` / `ctx.now()` | `Clock` / `Date` | frozen and advanceable in tests. Never `Date.now()` |
|
|
59
|
+
| `ctx.logger` | `Logger` | structured, role-tagged, correlated to the span |
|
|
60
|
+
| `ctx.signal` | `AbortSignal` | client disconnect / drain / the request deadline; pass it to long calls. A write past the deadline rethrows `X_TIMEOUT` and rolls back; an `idempotent` action called with a key runs to its end instead, on a fresh signal with no deadline, so the retry replays success (`runToCompletion`), under a hard ceiling: twice `requestTimeoutMs` by default (60 s), `configureIdempotency({ scope, ceilingMs })` to change it. Past it the handler is aborted and the key replays `X_IDEMPOTENCY_RUN_CEILING` |
|
|
61
|
+
| `ctx.role` / `ctx.buildId` | `Role` / `string` | which runtime role is executing, and which build |
|
|
62
|
+
|
|
63
|
+
## Usage meters: `meter:`
|
|
64
|
+
|
|
65
|
+
A meter counts calls per tenant per period, durably, inside `invoke`. Prices, plans and the payment provider stay with the app: the framework resolves a number from `ctx` and counts the draw. `As of 2026-11`.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { action, t } from '@ultimat3/action';
|
|
69
|
+
import { allow } from '@ultimat3/policy';
|
|
70
|
+
|
|
71
|
+
const PER_DAY = new Map([['org-free', 500]]);
|
|
72
|
+
const quotaWarning = { enqueue: async () => undefined }; // in an app: a job({ input: MeterWarning, … })
|
|
73
|
+
|
|
74
|
+
export const callTool = action({
|
|
75
|
+
input: t.object({ prompt: t.string }),
|
|
76
|
+
output: t.object({ ok: t.boolean }),
|
|
77
|
+
policy: allow(),
|
|
78
|
+
rateLimit: { windowMs: 60_000, limit: ({ ctx }) => (ctx.actor.orgId === undefined ? 10 : 60) },
|
|
79
|
+
meter: {
|
|
80
|
+
name: 'calls',
|
|
81
|
+
period: 'day',
|
|
82
|
+
limit: ({ ctx }) => PER_DAY.get(ctx.actor.orgId ?? '') ?? 0,
|
|
83
|
+
warnAt: 0.8,
|
|
84
|
+
onWarn: quotaWarning,
|
|
85
|
+
},
|
|
86
|
+
handle: () => ({ ok: true }),
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
| Rule | What happens |
|
|
91
|
+
|---|---|
|
|
92
|
+
| where | drawn **once per call, inside `invoke`**, on HTTP, MCP, an agent's tool call, `.job()` and an in-process call — after the actor half, the rate limit, the parse and the policy, before `handle`. A call refused by any of those uses none of the allowance |
|
|
93
|
+
| whose counter | the tenant (`ctx.actor.orgId`), else the signed-in actor, else one shared `anonymous` counter — refused together, never unlimited. A job run carries its org (`jobRunActor`) |
|
|
94
|
+
| period | **UTC** boundaries (`hour`, `day`, `month`); the stored key spells the zone (`day@UTC`), so a zone-aware period added later is a new counter, never a reinterpreted one. Two actions naming one meter with one period share the counter |
|
|
95
|
+
| limit | a whole number ≥ 0 (`0` refuses every call), or a function of `{ ctx }`, sync or async, resolved per call — a plan change applies to the next call. Anything else is `X_METER_INVALID` (500), at `action()` for a number, per call for a resolver |
|
|
96
|
+
| atomic | one conditional increment: `insert … on conflict … where used + 1 <= limit` in Postgres, a synchronous check-and-add in memory. N parallel calls at the edge admit exactly the remainder |
|
|
97
|
+
| retries | an idempotent call draws inside its reservation: a replay — of a success or a settled failure — draws nothing; a refusal releases the key, so the same key runs once the period resets. A non-idempotent retry is a new call and draws again |
|
|
98
|
+
| refusal | `X_METER_EXCEEDED`, 429, `meta: { meter, limit, resetsAt, retryAfterSeconds }`; over HTTP `Retry-After` is the seconds to the period's end and the problem document carries `meta: { meter, limit, resetsAt }` — `@ultimat3/http`'s reviewed framework row (`problem-meta-framework.ts`), every other key stays server-side. `retry: 'retry-after'`, so a job waits rather than spending attempts |
|
|
99
|
+
| `warnAt` + `onWarn` | both or neither. Crossing `warnAt × limit` claims the period's warning once per tenant (deduplicated across replicas and restarts), then calls `onWarn.enqueue(warning, { outbox: false, tenantId })` — a `job({ input: MeterWarning, … })` satisfies it. An enqueue that fails is logged, never fails the call, and the claim is released for the next draw |
|
|
100
|
+
| store | `memoryMeterStore()` by default (`scope: 'process'`, bounded); the CLI boot installs `postgresMeterStore({ executor })` over `x_meter`, one row per tenant × meter × period declaration rolled over in place — bounded by tenants, so it ships no purge. No usage history: an app that bills on usage records its own |
|
|
101
|
+
| published | `describe().meter` (`{ name, period, timeZone: 'UTC', limit, warnAt }`, `limit: null` for a resolver), `x-ultimate.meter` in `openapi.json` (absent when undeclared) — with `defineApi({ openapi })`, a 429 naming `X_METER_EXCEEDED` with `Retry-After`, public operation or not — and `actions[].meter` in `x.manifest.json`, the same shape |
|
|
102
|
+
| contract diff | breaking when a call pattern the old meter admitted is refused: a meter introduced, a lower limit, a longer period not covering the old one at full use (100/day → month needs ≥ 3,100), a rename (`meta.meter` is on the wire), a move to or from a per-call limit. A shorter period at the same limit admits MORE (100/day → 100/hour): additive, as are a higher limit and a removed meter. `warnAt` is internal. `As of 2026-11` |
|
|
103
|
+
| tests | the app preload resets the meter store at every file boundary, as it does the rate-limit store: a test file never resets it itself |
|
|
104
|
+
|
|
105
|
+
## Streaming: `stream:` and an async generator
|
|
106
|
+
|
|
107
|
+
An action that answers in pieces declares `stream: <chunk schema>` and writes `handle` as `async *handle({ input, ctx })`: each `yield` is a chunk, the `return` is the output.
|
|
108
|
+
|
|
109
|
+
| Caller | Gets |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `accept: text/event-stream` on its route | `event: chunk` per yield (parsed by `stream` first), then one `event: done` (output) or `event: error` (problem document); `x-accel-buffering: no`, a `: keep-alive` every 15 s |
|
|
112
|
+
| anything else — JSON, MCP, `.job()`, `.as()`, a direct call | the generator drained, the output alone: one contract |
|
|
113
|
+
| refused before the handler starts (policy, input, rate limit) | the ordinary problem response, never a stream |
|
|
114
|
+
| the client leaves mid-stream | `ctx.signal` aborts inside the handler |
|
|
115
|
+
| a browser | `clientStream({ url, body })` from `@ultimat3/core`: `fetch`, never `EventSource` |
|
|
116
|
+
|
|
117
|
+
A chunk its schema refuses, or `stream:` beside a handle that is not a generator, is `X_ACTION_STREAM_INVALID`. The OpenAPI 200 lists `text/event-stream` beside `application/json` for a streaming action only. `agent()` streams this way.
|
|
118
|
+
|
|
119
|
+
## The fluent surface
|
|
120
|
+
|
|
121
|
+
Every projection this package owns is a method on the action — `publishPost.openapi()`, never `toOpenApiOperation(publishPost)`. The MCP tool is the exception by tier: `@ultimat3/mcp`'s one projection, `toolFrom(publishPost)` (`As of 25.0.0` there is no `.tool()`). Exactly four declared fields are lifted onto it as readable properties: `input`, `output`, `policy`, `mcp`. The rest of the declaration is structured metadata, reachable through `describe()` and nowhere else. An action has no `.def`.
|
|
122
|
+
|
|
123
|
+
| Member | Is | Rule |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| `publishPost(input, options?)` | the mutation | parse `input` → load `row` → evaluate `policy` → `handle` → parse `output` |
|
|
126
|
+
| `.as(actor, input, options?)` | the same mutation, as someone else | keeps the surrounding context whole — services, clock, locale, trace — and swaps only the actor. `null` is the signed-out caller |
|
|
127
|
+
| `.openapi()` | the OpenAPI 3.1 operation | byte-stable; `x verify` diffs it for contract drift |
|
|
128
|
+
| `.client({ baseUrl })` | the typed RPC method | derives `POST /api/posts/publish` by string math, so the browser imports no server code |
|
|
129
|
+
| `.job()` | the durable-work handle | the same handler, run through the queue as `action:publishPost` |
|
|
130
|
+
| `.contract({ exercise })` | the generated assertions | garbage rejected, anonymous denied, spec present, and the handler run 16 times on generated legal input ([Testing](Testing#the-exercise-assertion)) |
|
|
131
|
+
| `.describe()` | the manifest row | `kind`, `name`, `verb`, `resource`, `method`, `path`, `capability`, `input`, `output`, `invalidates`, `idempotent`, `mutator`, `mcp`, `rateLimit` — and the only reader for the declared metadata that is not lifted |
|
|
132
|
+
| `.input` `.output` `.policy` `.mcp` | the declaration, lifted | the whole lifted set — readable, and `.mcp` present only when declared. `cache.invalidates`, `rateLimit` and `idempotent` are not properties: read them off `describe()`, where `cache.invalidates` flattens to `invalidates` |
|
|
133
|
+
|
|
134
|
+
`handle` and `row` — the declaration's two functions — are unreachable by design: neither lifted nor described. The declaration lives in a private store inside `invoke.ts` and `@ultimat3/action` exports no reader for it, so `invoke` is the only thing that can run them — one execution path and one authz path, structurally rather than by convention. A hand-rolled object with `kind: 'action'` is `X_ACTION_FOREIGN`, never a registered action.
|
|
135
|
+
|
|
136
|
+
Every projection needs the name `registerActions()` stamps on. It names the export in place, so `import { publishPost }` is the action that projects once the app has booted — there is no second, differently-named twin to remember.
|
|
137
|
+
|
|
138
|
+
## Six generated artifacts
|
|
139
|
+
|
|
140
|
+
| # | Artifact | Derived from | Notes |
|
|
141
|
+
|---|---|---|---|
|
|
142
|
+
| 1 | **HTTP route** | name + `input` | `POST /api/posts/publish` — first word is the verb, the rest is the pluralized resource (the default `pathStyle: 'resource'`); `defineApi({ http: { pathStyle: 'readable' } })` makes it `/api/publish-post`, the kebab-cased name, and `http.path` pins one. Body parsed by `input`, errors are `UltimateError` JSON |
|
|
143
|
+
| 2 | **OpenAPI operation** | `input` + `output` + `mcp.description` | `publishPost.openapi()`, emitted into `x.manifest.json` and `openapi.json`; contract diff runs in `x verify` |
|
|
144
|
+
| 3 | **Typed client function** | `input` + `output` | one map-wide client, `export const client = rpc<Api['actions']>({ baseUrl })`, then `await client.publishPost({ postId })` in `app/`; or `publishPost.client({ baseUrl })` for a single method. `rpc` is the only name for the map-wide client — no `createClient` alias, no fetch, no codegen step to remember. `x new` writes it as `apps/web/shared/browser-client.ts` (`browserClient`, beside `browserQueries`); it typechecks at any app size (pinned at 300 actions in 100 modules). **`rpc` and `queryClient` do not reach a mount**: under `baseUrl: '…/v1'` they derive `/v1/api/…` and a valid token answers 404. A machine caller uses the mount's OpenAPI document |
|
|
145
|
+
| 4 | **Job handle** | the whole declaration | `publishPost.job()` — a namespaced name, an `idempotencyKey` from the payload, and an `invoke` that runs the same handler durably. Register it with the queue; `.enqueue()` belongs to a declared `job` |
|
|
146
|
+
| 5 | **MCP tool** | `mcp` + `input` + `policy` | `toolFrom(publishPost)` from `@ultimat3/mcp` — one tool named `publishPost` per exposed action, exactly the `tools/list` entry, JSON Schema from `input`, every call through this action's `invoke` (authz unchanged) |
|
|
147
|
+
| 6 | **Test** | `input` + `policy` + `handle` | `publishPost.contract({ exercise: { actor } })` — schema round-trip, a denial test per policy branch, and the handler on generated legal input, generated green, not as a `TODO` |
|
|
148
|
+
|
|
149
|
+
**A client for code that cannot import the app.** `rpc` needs the app's action types, so a customer's TypeScript never had one. `x client customer --out <dir>` compiles the committed `openapi.json` (or a bearer mount's own document, `--mount /v1`) into `index.ts` + `types.ts` with no import beyond the platform: `createClient({ baseUrl, headers })`, one method per `operationId`, a non-2xx as `ProblemError`, and `refusalOf(error, 'publishPost')` narrowing to the codes that operation declares under `errors:`. The calling direction is `x client vendor <name>` — `outbound()` and `t` schemas from a third party's document. One compiler for both, never a second way: [CLI reference](CLI-Reference#x-client). `As of 2026-11`.
|
|
150
|
+
|
|
151
|
+
**A bulk endpoint is one action.** `hive({ member, split, onMemberError })` (`@ultimat3/ai`) runs `member` once per item under its own policy and answers per-item outcomes — `{ index, status: 'ok' | 'failed', … }` — so a bulk operation is in OpenAPI, the typed client, the customer client and MCP by construction, with no batch route to hand-write.
|
|
152
|
+
|
|
153
|
+
Plus cache invalidation: `cache.invalidates` fans out to request memo, in-process LRU (all instances, over NATS), Redis, ISR pages, and the CDN purge webhook in one hop ([Caching and invalidation](Caching-And-Invalidation)).
|
|
154
|
+
|
|
155
|
+
There is **one execution path**. HTTP, MCP, jobs, and direct server calls differ only in the surface they declare, which selects how a denial is rendered — never whether authz runs and never how input is validated.
|
|
156
|
+
|
|
157
|
+
## The API as a whole — `defineApi({ http, openapi })`
|
|
158
|
+
|
|
159
|
+
`As of 22.6.0`. Declared once, beside the primitives, and read by the boot and `x manifest`:
|
|
160
|
+
|
|
161
|
+
| Key | Effect |
|
|
162
|
+
|---|---|
|
|
163
|
+
| `http.pathStyle` | `'resource'` (default — every existing URL stays) or `'readable'`: `signIn` → `/api/sign-in`, `health` → `/api/health`. A browser states nothing: the server stamps a non-default style into every document (`<meta name="ultimate-path-style">`) and `rpc({ baseUrl: '' })`, `useMutation` and the outbox replay derive under it. A caller with no document — a script, another service — passes `rpc({ baseUrl, pathStyle: 'readable' })`; the wrong one is answered 404 `X_CONTRACT_DRIFT` naming the style served and the action's real path, never a bare `X_ROUTE_NOT_FOUND`. The module scan evaluates `apps/web/api/index.ts` first, so a module-level `derivePath(name)` anywhere else sees the declared style; one evaluated earlier (inside the index's own imports) is refused at the declaration, `X_ACTION_PATH_DERIVED_EARLY` — derive inside the function that uses it |
|
|
164
|
+
| `http.mounts[]` | `{ prefix: '/v1', scopes, resolveToken, rateLimit?, openapi? }` — a second door onto a cut of the same routes. Only `Authorization: Bearer` authenticates there (a session cookie reaches nothing, so no CSRF proof is needed); a primitive outside the token's scopes answers **404** like MCP's unknown tool; no/invalid token **401** with `WWW-Authenticate: Bearer`; a per-token `rateLimit` answers `RateLimit-*` and **429** + `Retry-After`, and it **replaces** the `default` bucket on the mount's routes (no `rateLimit`, `default` stands; a route's own bucket and the tenant allowance still apply). A `Link: rel="successor-version"` names the mounted path, and is dropped when the successor is outside this token's cut. `scopes` is the map `defineAppMcp` takes; `resolveToken` the same resolver. `/api/create-case` → `/v1/create-case`, `/_x/query/case-list` → `GET /v1/case-list` |
|
|
165
|
+
| `http.mounts[].openapi` | a file (`'openapi.v1.json'`) `x manifest` writes with only the cut, bearer-secured; `x verify` refuses it stale or missing. Each operation's `security` is `[{ bearer: ['<its scope>'] }]` — the one scope of `scopes` that names it (also `x-ultimate.scope`) — and `components.securitySchemes.bearer` lists every scope with the operations it unlocks, in its `description` and as `x-ultimate.scopes` (`{ scope: [names] }`). `As of 22.12`; before, every operation said `bearer: []` |
|
|
166
|
+
| `openapi` | `{ title, version, description, servers, sessionCookie }` — any key makes `openapi.json` complete: `info`, `servers`, `securitySchemes` (`cookie`, plus `bearer` with a mount), `401` + `429` (with `Retry-After`) on every operation its route authenticates, `RateLimit-*` on rate-limited ones. Absent, the bytes are unchanged |
|
|
167
|
+
|
|
168
|
+
## One authz system
|
|
169
|
+
|
|
170
|
+
`policy` is evaluated for the HTTP call, the typed client call, the job execution, the MCP tool call, and the live-query subscription — the same function, the same actor resolution, the same denial error.
|
|
171
|
+
|
|
172
|
+
> **Two authz systems is how every Meteor-like framework died.** Meteor had `allow`/`deny` rules for the client sync path and plain method bodies for the server path; the two drifted, and the drift *was* the security model. Anything that lets an agent expose data through a second door — a "public" MCP tool, an "internal" RPC, a sync rule table — is a rejected design, not a config option.
|
|
173
|
+
|
|
174
|
+
Rejected by that rule: an MCP-specific permission table, a "trusted tool" mode, a broad-rights service account for agents, and an internal RPC surface that skips `policy`. Details in [Policies and authz](Policies-And-Authz).
|
|
175
|
+
|
|
176
|
+
## `action` must never
|
|
177
|
+
|
|
178
|
+
- read the request object, headers, or cookies directly — actor and tenant come from `ctx`.
|
|
179
|
+
One exception is a caller whose credential is a **signature over the raw body** — a signed
|
|
180
|
+
upload. `useRequestBodyBytes()` from `@ultimat3/http` returns the exact bytes the body was
|
|
181
|
+
parsed from (the same size-capped, cached read). Off HTTP (a job, MCP) it is `X_NO_REQUEST`.
|
|
182
|
+
A vendor's **webhook** never comes through an action: the input schema answers 400/422 before
|
|
183
|
+
the signature is checked, and `application/xml`, `application/octet-stream` or no content type
|
|
184
|
+
is `X_BODY_INVALID` with the handler never reached. The one door is `webhookReceiver()`
|
|
185
|
+
([Jobs and workflows](Jobs-And-Workflows#inbound-webhooks))
|
|
186
|
+
- render, redirect, or return HTML
|
|
187
|
+
- perform its own authorization inside `handle` — that belongs in `policy`
|
|
188
|
+
- do slow work inline — enqueue a `job`
|
|
189
|
+
- share a file with another primitive — one per file, in the feature's `actions/`
|
|
190
|
+
|
|
191
|
+
Logic lives in `service.ts`; `actions/` holds declarations. An action whose `handle` is more than a few lines of orchestration is a service waiting to be extracted.
|
|
192
|
+
|
|
193
|
+
## `mutator` — action + optimistic twin
|
|
194
|
+
|
|
195
|
+
Same declaration surface as `action`, plus a local half that runs client-side against the local store while the server call is in flight.
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
export const likePost = mutator({
|
|
199
|
+
// REQUIRED (`X_MUTATOR_NOT_IDEMPOTENT`): a replay under the same Idempotency-Key answers the
|
|
200
|
+
// first result instead of running `server` again.
|
|
201
|
+
idempotent: true,
|
|
202
|
+
// Convergent, not incremental: `local` replays on every rebase, so applying it N times has to
|
|
203
|
+
// equal applying it once — `likedByMe` is what makes the second application a no-op.
|
|
204
|
+
local(tx, { postId }) {
|
|
205
|
+
tx.posts.update(postId, (p) =>
|
|
206
|
+
p.likedByMe ? {} : { likedByMe: true, likeCount: p.likeCount + 1 });
|
|
207
|
+
},
|
|
208
|
+
async server(ctx, { postId }) { return ctx.posts.like(postId); },
|
|
209
|
+
conflict: 'server-wins', // | 'last-write-wins' | custom(merge)
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
| Aspect | Rule |
|
|
214
|
+
|---|---|
|
|
215
|
+
| Projects to | everything `action` does, plus a local-store transaction and a rebase entry |
|
|
216
|
+
| Owns | conflict strategy: `'server-wins'`, `'last-write-wins'`, or `custom(merge)` |
|
|
217
|
+
| Authz | the `server` half carries the policy; the `local` half is presentation only and never a security boundary |
|
|
218
|
+
| Idempotency | `idempotent: true` is required — a compile error, and `X_MUTATOR_NOT_IDEMPOTENT` at declaration. The client replays a mutator's write under one `Idempotency-Key` (a retry, the offline queue draining), and the replay answers the first result instead of running `server` again |
|
|
219
|
+
| Never | let `local` do I/O, randomness, or `Date.now()` |
|
|
220
|
+
|
|
221
|
+
**Replayability rule:** `local` is re-executed on every rebase — after each server confirmation, on reconnect, and when a conflicting remote write arrives. It must be a pure function of `(tx, input)`. I/O, `Math.random()`, `crypto.randomUUID()`, or a wall-clock read makes the local timeline diverge from the server's, and the divergence surfaces as flicker, then as wrong data. Ids and timestamps come from the input, generated once at call time. Tier 3 local-first (`persist: true`) has **not shipped**, `As of 2026-08`; mutators work today at realtime tiers 1–2 ([Realtime](Realtime)).
|
|
222
|
+
|
|
223
|
+
## Errors
|
|
224
|
+
|
|
225
|
+
| Code | When | Fix |
|
|
226
|
+
|---|---|---|
|
|
227
|
+
| `X_ACTION_POLICY_MISSING` | an action registered without `policy` | add `policy: can('<name>')` to the declaration |
|
|
228
|
+
| `X_ACTION_DUPLICATE` | two actions share an export name | rename one — names are globally unique |
|
|
229
|
+
| `X_INPUT_INVALID` | body fails the `input` schema — including a string (or a `t.record` key) carrying U+0000, which every string-backed `t` refuses because Postgres `text`/`jsonb` cannot store it. Tabs, newlines and the other C0 controls are accepted. `As of 2026-09-29` | `x actions show <name> --json` prints the expected schema |
|
|
230
|
+
| `X_FORBIDDEN` | the policy said no — one code for the direct call, the HTTP 403 and the MCP tool error | grant the capability, or call as an actor who has it |
|
|
231
|
+
| `X_UNAUTHENTICATED` | no session; anonymous actor hit a policy needing one (401) | sign in, or send a valid token |
|
|
232
|
+
| `X_IDEMPOTENCY_CONFLICT` | key reused with a different payload, or still in flight | send a fresh `Idempotency-Key`, or retry after the first settles |
|
|
233
|
+
| `X_IDEMPOTENT_REPLAY_REDACTED` | a replay of an answer whose secret-named field (`isRedactedKey`, or a `Secret`) was stored as `[redacted]` — the first call ran, its answer is not kept in plaintext (409) | read the effect with a query; a new `Idempotency-Key` runs the call again |
|
|
234
|
+
| `X_IDEMPOTENCY_OUTPUT_UNSERIALIZABLE` | a keyed handler's answer holds a value JSON has no form for (a `bigint`), so no retry could replay it. The handler ran, and a retry replays this refusal | return JSON from `handle`: `String(id)` for a `bigint`, then send a fresh `Idempotency-Key` |
|
|
235
|
+
| `X_IDEMPOTENCY_RESERVATION_LOST` | an idempotent action inside a transaction outlived the request deadline and a retry took its key; its transaction rolls back (409) | resend with the same `Idempotency-Key` |
|
|
236
|
+
| `X_ACTION_ERRORS_INVALID` | an `errors:` entry is not `X_SCREAMING_SNAKE`, has no registered 4xx status, or repeats | `defineError({ code, status })` first and import it, then list it once |
|
|
237
|
+
| `X_ERROR_UNDECLARED` | a test or dev run threw a 4xx code outside `errors:`, or `.contract()`'s scan found one | add `'<CODE>'` to `errors:`, or stop throwing it there |
|
|
238
|
+
| `X_MUTATOR_NOT_IDEMPOTENT` | a `mutator` declared without `idempotent: true` | add `idempotent: true` to the definition |
|
|
239
|
+
| `X_CONTRACT_DRIFT` | client build id ≠ server build id, or a breaking published-contract change | reload the client / bump the action version |
|
|
240
|
+
| `X_TENANCY_UNSCOPED` | a query inside `handle` had no tenant predicate | scope it through the repo, never raw SQL |
|
|
241
|
+
| `X_BOUNDARY_VIOLATION` | action declared outside `api/` or `<feature>/actions/` | move the file, or `x fix boundary <file>` |
|
|
242
|
+
| `X_LAYOUT_SIBLING_MODULE` | a module `actions.ts` beside the directory `actions/` | move each declaration to `actions/<name>.ts`, then delete the file |
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
X_ACTION_POLICY_MISSING: action registered without a policy
|
|
246
|
+
cause: action "publishPost" was registered without a policy
|
|
247
|
+
fix: add `policy: can('publishPost')` to the action definition in the file that exports it
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Every code carries the same `{ code, cause, fix, docs }` in the terminal, the dev overlay, the HTTP body, `--json`, and the MCP tool error. Full list: [Error codes](Error-Codes).
|
|
251
|
+
|
|
252
|
+
A handler's OWN refusals are app codes, each declared once with `defineError` from `@ultimat3/core`
|
|
253
|
+
— the title, the status the HTTP projection answers and the class `handle` throws:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
import { defineError } from '@ultimat3/core';
|
|
257
|
+
|
|
258
|
+
export const PostNotFound = defineError<string>({
|
|
259
|
+
code: 'X_POST_NOT_FOUND',
|
|
260
|
+
title: "no such post in the actor's organisation",
|
|
261
|
+
status: 404,
|
|
262
|
+
cause: (ref) => `no post ${ref} in the actor's organisation`,
|
|
263
|
+
fix: 'x queries list --json, then pass an id the post read returns',
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// in handle: throw new PostNotFound(input.postId);
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
A code with no status answers 500 and hides its cause from the caller; `offSocket: true` declares a
|
|
270
|
+
code no response carries. `guards/error-declared.ts` refuses a handwritten `UltimateError` subclass.
|
|
271
|
+
|
|
272
|
+
### Declared refusals (`errors:`)
|
|
273
|
+
|
|
274
|
+
`errors: [PostNotFound.code, 'X_FORBIDDEN']` names the 4xx codes a caller may branch on. Import
|
|
275
|
+
the class and list its `.code`: the import is what registers the status before the list is
|
|
276
|
+
checked. Judged at the one place a handler runs: a throw out of `handle` or `row` only — the
|
|
277
|
+
policy, the parse, the rate limit and idempotency sit outside, so `X_FORBIDDEN` from the policy or
|
|
278
|
+
`X_INPUT_INVALID` is never judged.
|
|
279
|
+
|
|
280
|
+
| Thrown out of `handle`/`row` | Test, development | Production (or no environment named) |
|
|
281
|
+
|---|---|---|
|
|
282
|
+
| a listed code | itself | itself |
|
|
283
|
+
| a 4xx code not listed | `X_ERROR_UNDECLARED`, the original as `sourceError`, the fix naming the code to add | itself, `logger.warn` + `undeclared_errors_total{primitive,name,code}` |
|
|
284
|
+
| a 5xx, a statusless code, `X_ABORTED`, `X_DRAINING`, a non-coded throw | itself | itself |
|
|
285
|
+
|
|
286
|
+
- **OpenAPI**: each code joins `responses[status]`. A status only the list answers narrows
|
|
287
|
+
`Problem.type` to an enum of `urn:ultimate:error:<CODE>`; a status the framework already answers
|
|
288
|
+
(`400`, `403`, `409`) keeps its open `Problem` and names the code in its description. Both carry
|
|
289
|
+
`x-ultimate-errors: [<codes>]`. Undeclared, the document's bytes are unchanged.
|
|
290
|
+
- **`.contract()`** gains one assertion: a code literal or a `defineError` class in `handle`'s or
|
|
291
|
+
`row`'s own source, 4xx and outside the list, fails it. First hop only — a code thrown behind a
|
|
292
|
+
helper is judged when a test reaches it (the generated exercise runs included). A path nothing
|
|
293
|
+
reaches stays unjudged; a class imported under another name is missed by the scan.
|
|
294
|
+
- **MCP and the in-app agent's tool**: the description ends `Declared refusals: <codes>.`
|
|
295
|
+
- **Nested `invoke`**: an inner action's declared refusal is the outer's to declare too — the
|
|
296
|
+
outer's caller receives it.
|
|
297
|
+
- **`x.manifest.json`**: `errors` on the action's fact; removing a code is breaking, adding one additive.
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
## Introspection
|
|
301
|
+
|
|
302
|
+
```
|
|
303
|
+
$ x actions list --json
|
|
304
|
+
{"ok":true,"command":"actions","summary":"1 action","findings":[],
|
|
305
|
+
"data":[{"kind":"action","mutator":false,"name":"publishPost","verb":"publish",
|
|
306
|
+
"resource":"posts","method":"POST","path":"/api/posts/publish",
|
|
307
|
+
"capability":"post:publish", …}]}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Both subcommands emit the same `describe()` row — `list` one per action, `show` the named one. It is exactly what `publishPost.describe()` returns in process, so the CLI has no private view of a primitive:
|
|
311
|
+
|
|
312
|
+
```
|
|
313
|
+
$ x actions show publishPost --json
|
|
314
|
+
{"ok":true,"command":"actions","summary":"action publishPost","findings":[],
|
|
315
|
+
"data":{"kind":"action","mutator":false,"name":"publishPost","verb":"publish",
|
|
316
|
+
"resource":"posts","method":"POST","path":"/api/posts/publish",
|
|
317
|
+
"capability":"post:publish",
|
|
318
|
+
"input":{"type":"object","required":["postId"],
|
|
319
|
+
"properties":{"postId":{"type":"string","format":"uuid"},
|
|
320
|
+
"notify":{"type":"boolean","default":true}}},
|
|
321
|
+
"output":{"$ref":"#/components/schemas/PostView"},
|
|
322
|
+
"invalidates":["feed","post"],"idempotent":false,
|
|
323
|
+
"mcp":{"expose":true,"tool":"publishPost","description":"Publish a draft post"},
|
|
324
|
+
"rateLimit":null}}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`invalidates` is sorted and de-duplicated, so descriptor output never depends on declaration order — a diffable contract. `mutator` is `true` only for a `mutator()` declaration; `kind` stays `"action"` for both, which is why the flag is a field of its own and not a second `kind`. The same data is the MCP `actions.describe` tool (actions and queries in one call) and the `/_x` **Routes** panel.
|
|
328
|
+
|
|
329
|
+
## Generated contract test
|
|
330
|
+
|
|
331
|
+
Emitted with the action, and green on the first run: it pins the invariant the action owns — a non-owner is denied — rather than a `TODO` someone has to notice.
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
// contract test — generated with the action
|
|
335
|
+
test('publishPost denies a non-owner', async ({ seed, actorFor }) => {
|
|
336
|
+
const { post, stranger } = await seed('two-orgs');
|
|
337
|
+
await expect(publishPost.as(actorFor(stranger), { postId: post.id }))
|
|
338
|
+
.rejects.toBeUltimateError('X_FORBIDDEN');
|
|
339
|
+
});
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Runs against a cloned Postgres (`CREATE DATABASE … TEMPLATE`), never a mock. `x test contract` asserts the input/output schema round-trip, one denial per policy branch, and that the emitted OpenAPI operation plus MCP tool shape still match the declaration. See [Testing](Testing).
|