From 20eb4709a1b8b3fc6ed3df4277a9c19894dd5efc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:32:58 +0000 Subject: [PATCH 1/4] wip(storage): retire scope 'public' from StorageScopeSchema and refuse it at the upload doors Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN --- .../src/storage-routes.test.ts | 83 +++++++++++++++++++ .../service-storage/src/storage-routes.ts | 44 ++++++++++ packages/spec/src/api/storage.test.ts | 4 +- packages/spec/src/api/storage.zod.ts | 21 ++++- .../spec/src/system/object-storage.test.ts | 40 ++++++++- .../spec/src/system/object-storage.zod.ts | 64 ++++++++++---- 6 files changed, 236 insertions(+), 20 deletions(-) diff --git a/packages/services/service-storage/src/storage-routes.test.ts b/packages/services/service-storage/src/storage-routes.test.ts index f2e844ec8ed..8dd521c00f8 100644 --- a/packages/services/service-storage/src/storage-routes.test.ts +++ b/packages/services/service-storage/src/storage-routes.test.ts @@ -712,6 +712,89 @@ describe('Storage REST Routes', () => { }); }); + // ── [#22443] `scope: 'public'` never made a file public — the download doors + // above judge `acl`, the attachments scope and field ownership alone — so the + // two upload-starting doors refuse it by name, with the remedy, instead of + // storing a private file under a name that says otherwise. + describe("an upload naming scope 'public' is refused with the acl remedy (#22443)", () => { + const PRESIGNED = '/api/v1/storage/upload/presigned'; + const CHUNKED = '/api/v1/storage/upload/chunked'; + const bodyFor = (path: string, scope?: string) => ({ + filename: 'logo.png', + mimeType: 'image/png', + ...(path === PRESIGNED ? { size: 3 } : { totalSize: 3 }), + ...(scope === undefined ? {} : { scope }), + }); + const post = async (path: string, body: Record) => { + const res = createMockRes(); + await httpServer._getHandler('POST', path)!(createMockReq({ method: 'POST', body }), res); + return res; + }; + + it('refuses both doors 400 INVALID_REQUEST, naming acl public_read, before anything is stored or started', async () => { + const createFile = vi.spyOn(store, 'createFile'); + const createSession = vi.spyOn(store, 'createSession'); + const presign = vi.spyOn(adapter, 'getPresignedUpload'); + const initiate = vi.spyOn(adapter, 'initiateChunkedUpload'); + for (const path of [PRESIGNED, CHUNKED]) { + const res = await post(path, bodyFor(path, 'public')); + expect(res._status, path).toBe(400); + expect(res._json?.success, path).toBe(false); + expect(res._json?.error?.code, path).toBe('INVALID_REQUEST'); + expect(res._json?.error?.message, path).toMatch(/^scope 'public' is not accepted/); + expect(res._json?.error?.message, path).toContain("acl 'public_read'"); + } + expect(createFile).not.toHaveBeenCalled(); + expect(createSession).not.toHaveBeenCalled(); + expect(presign).not.toHaveBeenCalled(); + expect(initiate).not.toHaveBeenCalled(); + for (const spy of [createFile, createSession, presign, initiate]) spy.mockRestore(); + }); + + it('takes every other scope, and an omitted one, exactly as before (control)', async () => { + for (const path of [PRESIGNED, CHUNKED]) { + for (const scope of ['user', 'tenant', 'private', 'temp', 'attachments', undefined]) { + const res = await post(path, bodyFor(path, scope)); + expect(res._status, `${path} ${scope}`).toBe(200); + const file = await store.getFile(res._json.data.fileId); + expect(file?.scope, `${path} ${scope}`).toBe(scope ?? 'user'); + expect(file?.acl, `${path} ${scope}`).toBe('private'); + } + } + }); + + it('is right that the upload request carries no acl: a body acl is not stored', async () => { + // The refusal sends the caller to the stored file record, not to this + // request — because this is what the request does with an acl. + const res = await post(PRESIGNED, { ...bodyFor(PRESIGNED, 'user'), acl: 'public_read' }); + expect(res._status).toBe(200); + expect((await store.getFile(res._json.data.fileId))?.acl).toBe('private'); + }); + + it("leaves the download doors' verdict on an already-stored public-scoped row unchanged", async () => { + const server = createMockHttpServer(); + const s = new StorageMetadataStore(null); + registerStorageRoutes(server as any, adapter, s, { + basePath: '/api/v1/storage', + resolveSession: async (req: any) => (req.headers?.authorization ? { userId: 'user-7' } : null), + }); + await s.createFile({ + id: 'legacy-public', key: 'public/legacy-public.png', name: 'logo.png', + status: 'committed', acl: 'private', scope: 'public', + } as any); + const download = async (headers: Record) => { + const res = createMockRes(); + await server._getHandler('GET', '/api/v1/storage/files/:fileId/url')!( + createMockReq({ params: { fileId: 'legacy-public' }, headers }), res); + return res; + }; + const anonymous = await download({}); + expect(anonymous._status).toBe(401); + expect(anonymous._json?.error?.code).toBe('AUTH_REQUIRED'); + expect((await download({ authorization: 'Bearer t' }))._status).toBe(200); + }); + }); + describe('PUT/GET /_local/raw/:token', () => { it('should accept raw upload with valid token and serve download', async () => { // Generate a presigned upload diff --git a/packages/services/service-storage/src/storage-routes.ts b/packages/services/service-storage/src/storage-routes.ts index f73222e1b85..b1491750c26 100644 --- a/packages/services/service-storage/src/storage-routes.ts +++ b/packages/services/service-storage/src/storage-routes.ts @@ -121,6 +121,37 @@ function uploadTooLargeMessage(measured: string, maxUploadBytes: number): string const INCOMPLETE_UPLOAD_STATUS = 409; const INCOMPLETE_UPLOAD_CODE: StandardErrorCode = 'RESOURCE_CONFLICT'; +/** + * [#22443] The answer both upload-starting doors (presigned, chunked) give a + * request naming `scope: 'public'` — before the size check, before any row is + * written and before any URL or backend upload is started. + * + * The scope never made a file public. Since #22431 the download doors judge a + * file by `acl: 'public_read'`, the attachments scope and field ownership + * alone, so a `public`-scoped upload was stored as a private file, with no + * word to its caller, under a name promising what the runtime does not do. + * ADR-0104 makes `acl: 'public_read'` the one opt-in for anonymous download; + * the scope is refused rather than enforced, because enforcing it would hand + * every uploader a second door to anonymity (the triage ruling on #22443). + * The spec retires the same member from `StorageScopeSchema`. + * + * `400` / `INVALID_REQUEST`: the code these doors already answer a request + * they cannot take with, registered under this package in the ADR-0112 + * ledger. The message carries the remedy and where it lives — on the stored + * file record, because the upload request carries no `acl` (every upload is + * stored `acl: 'private'`), so a caller adding one to this request would get + * a private file again. + */ +const RETIRED_UPLOAD_SCOPE = 'public'; +const RETIRED_UPLOAD_SCOPE_STATUS = 400; +const RETIRED_UPLOAD_SCOPE_CODE: RegisteredErrorCode = 'INVALID_REQUEST'; +const RETIRED_UPLOAD_SCOPE_MESSAGE = + "scope 'public' is not accepted: a storage scope never made a file publicly readable. " + + "A file is served without sign-in only when its stored file record carries acl 'public_read' (ADR-0104). " + + "Upload with another scope, or omit scope for the default 'user', then set acl 'public_read' on the stored " + + 'file record of each file that must be readable before sign-in. The upload request carries no acl: every ' + + 'upload is stored private.'; + /** * [#22332] How many times the chunk door tries to record a stored chunk in the * upload's progress before it refuses. @@ -351,6 +382,14 @@ export function registerStorageRoutes( return false; }; + // [#22443] The scope gate the two upload-starting doors ask before the size + // gate. `false` ⇒ the 400 was already sent and the handler must stop. + const requireAcceptedUploadScope = (scope: unknown, res: IHttpResponse): boolean => { + if (scope !== RETIRED_UPLOAD_SCOPE) return true; + sendError(res, RETIRED_UPLOAD_SCOPE_STATUS, RETIRED_UPLOAD_SCOPE_CODE, RETIRED_UPLOAD_SCOPE_MESSAGE); + return false; + }; + // ── Download session gate (#22431) ─────────────────────────────────── // The one refusal both download doors give a caller with no session — // for a file of the unclaimed class below, and for a parent-governed file @@ -723,6 +762,8 @@ export function registerStorageRoutes( sendError(res, 400, 'INVALID_REQUEST', 'filename, mimeType, and size are required'); return; } + // [#22443] A retired scope is refused before anything is stored or minted. + if (!requireAcceptedUploadScope(scope, res)) return; // [#22283] The declared size against the saved limit — before the // pending row and before any URL is minted. On the local adapter the // bytes are judged again at `_local/raw`; an S3 URL takes them straight @@ -853,6 +894,9 @@ export function registerStorageRoutes( sendError(res, 400, 'INVALID_REQUEST', 'filename, mimeType, and totalSize are required'); return; } + // [#22443] Same scope gate as the presigned door — before the file row, + // the backend multipart and the session row. + if (!requireAcceptedUploadScope(scope, res)) return; // [#22283] The declared total against the saved limit — before the file // row, the backend multipart and the session row. The chunk door judges // the bytes as they arrive. diff --git a/packages/spec/src/api/storage.test.ts b/packages/spec/src/api/storage.test.ts index ef0a9f31b0c..87cd836bdcd 100644 --- a/packages/spec/src/api/storage.test.ts +++ b/packages/spec/src/api/storage.test.ts @@ -34,10 +34,10 @@ describe('GetPresignedUrlRequestSchema', () => { filename: 'image.png', mimeType: 'image/png', size: 2048, - scope: 'public', + scope: 'tenant', bucket: 'media-bucket', }); - expect(req.scope).toBe('public'); + expect(req.scope).toBe('tenant'); expect(req.bucket).toBe('media-bucket'); }); diff --git a/packages/spec/src/api/storage.zod.ts b/packages/spec/src/api/storage.zod.ts index beefdbd0816..3ea5a2f89ed 100644 --- a/packages/spec/src/api/storage.zod.ts +++ b/packages/spec/src/api/storage.zod.ts @@ -17,11 +17,28 @@ import { FileMetadataSchema } from '../system/object-storage.zod'; // ========================================== import { lazySchema } from '../shared/lazy-schema'; + +// The upload requests' `scope` — the scope a new `sys_file` row is filed under +// (`service-storage` stores it and prefixes the storage key with it). +// +// Not an access setting, and it never was (#22443): the download doors judge a +// file by its `acl`, the `attachments` scope and field ownership alone, so the +// upload doors refuse `public` by name rather than store a private file under a +// name that says otherwise. Anonymous download is a file's `acl: 'public_read'` +// (ADR-0104), set on the stored file record — these requests carry no `acl`, +// and every upload is stored private. Module-private and written with `//`: +// prose the two `scope` describes consume, not documented surface. +const UPLOAD_SCOPE_DESCRIPTION = + 'Storage scope the new file is filed under (default user; attachments for the record attachments ' + + 'surface). Not an access setting: the upload doors refuse public, and a file is served without ' + + "sign-in only when its stored file record carries acl 'public_read' (ADR-0104). The upload request " + + 'carries no acl; every upload is stored private.'; + export const GetPresignedUrlRequestSchema = lazySchema(() => z.object({ filename: z.string().describe('Original filename'), mimeType: z.string().describe('File MIME type'), size: z.number().describe('File size in bytes'), - scope: z.string().default('user').describe('Target storage scope (e.g. user, private, public)'), + scope: z.string().default('user').describe(UPLOAD_SCOPE_DESCRIPTION), bucket: z.string().optional().describe('Specific bucket override (admin only)'), })); @@ -148,7 +165,7 @@ export const InitiateChunkedUploadRequestSchema = lazySchema(() => z.object({ totalSize: z.number().int().min(1).describe('Total file size in bytes'), chunkSize: z.number().int().min(5242880).default(5242880) .describe('Size of each chunk in bytes (minimum 5MB per S3 spec)'), - scope: z.string().default('user').describe('Target storage scope'), + scope: z.string().default('user').describe(UPLOAD_SCOPE_DESCRIPTION), bucket: z.string().optional().describe('Specific bucket override (admin only)'), metadata: z.record(z.string(), z.string()).optional().describe('Custom metadata key-value pairs'), })); diff --git a/packages/spec/src/system/object-storage.test.ts b/packages/spec/src/system/object-storage.test.ts index 378a0009247..e6aef1edce8 100644 --- a/packages/spec/src/system/object-storage.test.ts +++ b/packages/spec/src/system/object-storage.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; import { StorageScopeSchema, FileMetadataSchema, @@ -42,12 +43,49 @@ describe('StorageScopeSchema', () => { expect(() => StorageScopeSchema.parse('data')).not.toThrow(); expect(() => StorageScopeSchema.parse('logs')).not.toThrow(); expect(() => StorageScopeSchema.parse('config')).not.toThrow(); - expect(() => StorageScopeSchema.parse('public')).not.toThrow(); }); it('should reject invalid storage scopes', () => { expect(() => StorageScopeSchema.parse('invalid')).toThrow(); }); + + // #22443 (ADR-0049): `public` never made a file public — anonymous download + // is a file's `acl: 'public_read'` (ADR-0104) — so the member was retired. + describe('retired member `public`', () => { + it('is refused at parse with the prescription naming acl public_read', () => { + const result = StorageScopeSchema.safeParse('public'); + expect(result.success).toBe(false); + const message = result.error!.issues[0]!.message; + expect(message).toMatch(/`public` was removed from `StorageScope`.*ADR-0049/s); + expect(message).toContain("acl: 'public_read'"); + expect(message).toContain('ADR-0104'); + }); + + it('is refused where it is authored, on ObjectStorageConfig.scope', () => { + const result = ObjectStorageConfigSchema.safeParse({ + name: 'assets_storage', + label: 'Assets Storage', + provider: 's3', + scope: 'public', + connection: {}, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'scope'); + expect(issue?.message).toContain("acl: 'public_read'"); + }); + + it('leaves an unknown value on zod\'s own message (control)', () => { + const result = StorageScopeSchema.safeParse('publik'); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).not.toContain('was removed'); + }); + + it('is no longer a StorageScope at compile time', () => { + // @ts-expect-error — `public` left the enum (tsc channel of the retirement) + const retired: z.input = 'public'; + expect(retired).toBe('public'); + }); + }); }); describe('FileMetadataSchema', () => { diff --git a/packages/spec/src/system/object-storage.zod.ts b/packages/spec/src/system/object-storage.zod.ts index 215e459d285..1d6355eea64 100644 --- a/packages/spec/src/system/object-storage.zod.ts +++ b/packages/spec/src/system/object-storage.zod.ts @@ -8,7 +8,7 @@ import { SystemIdentifierSchema } from '../shared/identifiers.zod'; * * Unified storage protocol that combines: * - Object storage systems (S3, Azure Blob, GCS, MinIO) - * - Scoped storage configuration (temp, cache, data, logs, config, public) + * - Scoped storage configuration (temp, cache, data, logs, config) * - Multi-cloud storage providers * - Bucket/container configuration * - Access control and permissions @@ -21,24 +21,58 @@ import { SystemIdentifierSchema } from '../shared/identifiers.zod'; // Storage Scope Protocol (formerly from scoped-storage.zod.ts) // ============================================================================ +import { lazySchema } from '../shared/lazy-schema'; +import { enumWithRetiredValues, retiredKey } from '../shared/retired-key'; + +// ── Retired storage scope member (ADR-0049 enforce-or-remove, #22443) ─────── +// +// `public` described "publicly accessible static assets", and no scope ever +// made a file publicly readable: no runtime parses `ObjectStorageConfigSchema`, +// and the scope the platform does store — `sys_file.scope`, written by the +// `service-storage` upload doors — never decided anonymity either. The download +// doors judge a file by its `acl`, the attachments scope and field ownership +// alone, so a `public`-scoped file was a private file whose name said +// otherwise. ADR-0104 makes `acl: 'public_read'` the one opt-in for anonymous +// download; enforcing the scope instead would have opened a second door to +// anonymity, which the triage ruling on #22443 declined. The upload doors +// refuse the value by name too (`storage-routes.ts`). +// +// A VALUE-level retirement (`enumWithRetiredValues`, shared/retired-key.ts): +// the member left the enum, so `tsc` refuses it, and the parse answers it with +// the prescription below instead of zod's anonymous enum message. No ADR-0087 +// conversion: no metadata type carries this schema, so `os migrate meta` has +// no authored source to rewrite. Module-private and written with `//`, never +// `/** */`: prose an enum's error map consumes, not documented surface. +const STORAGE_SCOPE_PUBLIC_RETIRED = + '`public` was removed from `StorageScope` in @objectstack/spec 17.8.0 (ADR-0049 ' + + 'enforce-or-remove) — no storage scope ever made a file publicly readable. A file is ' + + 'served without sign-in only when its stored file record carries `acl: \'public_read\'` ' + + '(ADR-0104), the one opt-in for anonymous download. Name another scope, or omit `scope` ' + + 'for the default `global`, and set `acl: \'public_read\'` on each file that must be ' + + 'readable before sign-in.'; + /** * Storage Scope Enum * Defines the lifecycle and persistence guarantee of the storage area. + * + * A scope is never an access setting: `public` was retired (ADR-0049) and is + * answered at parse with its prescription. Anonymous download is a file's + * `acl: 'public_read'` (ADR-0104). */ -import { lazySchema } from '../shared/lazy-schema'; -import { retiredKey } from '../shared/retired-key'; -export const StorageScopeSchema = lazySchema(() => z.enum([ - 'global', // Global application-wide storage - 'tenant', // Tenant-scoped storage (multi-tenant apps) - 'user', // User-scoped storage - 'session', // Session-scoped storage (ephemeral) - 'temp', // Ephemeral, cleared on restart - 'cache', // Ephemeral, survives restarts, cleared on LRU/Expiration - 'data', // Persistent, backed up - 'logs', // Append-only, rotated - 'config', // Read-heavy, versioned - 'public' // Publicly accessible static assets -]).describe('Storage scope classification')); +export const StorageScopeSchema = lazySchema(() => enumWithRetiredValues( + [ + 'global', // Global application-wide storage + 'tenant', // Tenant-scoped storage (multi-tenant apps) + 'user', // User-scoped storage + 'session', // Session-scoped storage (ephemeral) + 'temp', // Ephemeral, cleared on restart + 'cache', // Ephemeral, survives restarts, cleared on LRU/Expiration + 'data', // Persistent, backed up + 'logs', // Append-only, rotated + 'config', // Read-heavy, versioned + ], + { public: STORAGE_SCOPE_PUBLIC_RETIRED }, +).describe('Storage scope classification')); export type StorageScope = z.input; From b2695dfd671044b68e03cc08b4347793eca349ef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:35:08 +0000 Subject: [PATCH 2/4] feat(spec): register the D3 entry and changeset for the retired storage scope public Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN --- .../22443-storage-scope-public-retired.md | 39 +++++++++++++++++++ .../18.storage-scope-public-retired.ts | 39 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 35 +++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 .changeset/22443-storage-scope-public-retired.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.storage-scope-public-retired.ts diff --git a/.changeset/22443-storage-scope-public-retired.md b/.changeset/22443-storage-scope-public-retired.md new file mode 100644 index 00000000000..738e1d3fc41 --- /dev/null +++ b/.changeset/22443-storage-scope-public-retired.md @@ -0,0 +1,39 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-storage': minor +--- + +feat(storage)!: the storage scope `public` is retired — no scope ever made a file publicly readable, and `acl: 'public_read'` stays the one opt-in for anonymous download (#22443) + +Clause-②: no (narrowing) + + + +**BREAKING** — an accept-set narrowing on two published surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings. + +`StorageScopeSchema` described `public` as "publicly accessible static assets", and the upload doors stored a caller's `scope: 'public'` on the new file. Neither ever made a file public. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope `public` and the default acl was stored private, needed a signed-in caller, and nobody was told (ADR-0049 enforce-or-remove). ADR-0104 makes `acl: 'public_read'` the one opt-in for anonymous download, so the scope is retired, not enforced: enforcing it would have let any uploader make a file anonymous at upload. + +### What now refuses `public` + +- **The presigned upload door and the chunked upload door** (`@objectstack/service-storage`) answer an upload naming `scope: 'public'` with `400 INVALID_REQUEST`, before any file row, session row, upload URL or backend upload exists. The message names the remedy. Every other scope, and an omitted one, is taken exactly as before. +- **`StorageScopeSchema`** (`@objectstack/spec`): `public` left the enum. Writing it fails `tsc`, and parsing it, on its own or as `ObjectStorageConfig.scope`, fails with the prescription instead of zod's generic enum message. + +### FROM → TO + +| before | what to write instead | +| --- | --- | +| an upload with `scope: 'public'` | another scope, or no `scope` for the default `user`; then set `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in | +| `ObjectStorageConfig` with `scope: 'public'` | another scope, or no `scope` for the default `global` | + +**The one-line fix: stop naming scope `public`, and mark each file that must render before sign-in `acl: 'public_read'` on its stored file record.** The upload request carries no `acl`: every upload is stored `acl: 'private'`, so adding an `acl` to the upload request changes nothing. + +**Files already stored with scope `public`** are not touched. They download exactly as they did: a signed-in caller gets them, an anonymous one gets `401`, unless the file is `acl: 'public_read'`. + +### The retirement kit + +- **Schema.** `StorageScopeSchema` retires the member with `enumWithRetiredValues`. No D2 conversion: no metadata type carries this schema or the upload request, so `os migrate meta` has no authored source to rewrite. +- **D3 entry `storage-scope-public-retired`** carries the judgement no rewrite can make: whether a file that was uploaded as `public` must really be readable before sign-in. +- **Upload request contract.** The `scope` description on the presigned and chunked request schemas no longer offers `public` as an example, and says what the scope is and is not. +- **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries `StorageScope`. + +**Measured producers: none.** At origin/main da159f74e6, nothing in `packages/`, `examples/`, `apps/`, `skills/` or `content/docs/` uploads with scope `public` or declares an `ObjectStorageConfig` with it. The one hit was a `@objectstack/spec` request-schema test fixture, changed to `tenant` here. The same search finds 29 `scope: 'attachments'` writes, which is its control. At the `.objectui-sha` pin f0268ad784 and at objectui main 2063f7a, the upload adapter forwards a caller's scope and no caller names `public`: the console passes none, and the record attachments panel passes `attachments` (the control). Deployed callers and stored rows NOT MEASURED. diff --git a/packages/spec/src/migrations/entries/semantic/18.storage-scope-public-retired.ts b/packages/spec/src/migrations/entries/semantic/18.storage-scope-public-retired.ts new file mode 100644 index 00000000000..10958ebdb04 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.storage-scope-public-retired.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #22443 (ADR-0049 enforce-or-remove) — `public` left `StorageScope` +// (`enumWithRetiredValues`, system/object-storage.zod.ts), and the +// `service-storage` upload doors refuse an upload naming that scope with +// `400 INVALID_REQUEST`. Semantic only: no metadata type carries either +// surface, so there is no authored source for a D2 conversion to rewrite — +// what moves is caller code and the intent behind it, which only the caller +// can judge. +export const entry: SemanticMigration = { + id: 'storage-scope-public-retired', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: + 'the storage scope public — the scope of an upload request, presigned or chunked, and ' + + 'ObjectStorageConfig.scope (StorageScope)', + replacement: + 'another scope, or none for the default (`user` on an upload, `global` on a storage ' + + "configuration), and `acl: 'public_read'` on the stored file record of each file that must " + + 'be readable before sign-in (ADR-0104)', + reason: + 'A storage scope never made a file publicly readable. The download doors judge a file by its ' + + '`acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public ' + + 'and the default acl was stored private and needs a signed-in caller, while its scope said ' + + 'otherwise. The value is retired rather than enforced: enforcing it would let any uploader make ' + + "a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for " + + 'anonymous download. Whether a given file must be readable before sign-in is the caller\'s ' + + 'call, so no rewrite can make it: an upload that meant public needs its stored file record ' + + 'marked, and one that did not needs only another scope. The upload request itself carries no ' + + 'acl, and every upload is stored private. Files already stored with scope public are not ' + + 'touched and download exactly as before. ADR-0049', + acceptanceCriteria: + 'No upload call names scope public and no ObjectStorageConfig declares it; each upload that did ' + + 'now names another scope or none and is answered 200. Each file that must render before ' + + "sign-in has acl 'public_read' on its stored file record, and fetching it with no session " + + 'serves it; fetching any other uploaded file with no session is answered 401.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index eacf7bbd3e9..be324171018 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -19143,6 +19143,41 @@ const step18: MigrationStep = { + 'the only observable difference is that a startup result no longer carries ' + 'startTime beside durationMs.', }, + // #22443 (ADR-0049 enforce-or-remove) — `public` left `StorageScope` + // (`enumWithRetiredValues`, system/object-storage.zod.ts), and the + // `service-storage` upload doors refuse an upload naming that scope with + // `400 INVALID_REQUEST`. Semantic only: no metadata type carries either + // surface, so there is no authored source for a D2 conversion to rewrite — + // what moves is caller code and the intent behind it, which only the caller + // can judge. + { + id: 'storage-scope-public-retired', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: + 'the storage scope public — the scope of an upload request, presigned or chunked, and ' + + 'ObjectStorageConfig.scope (StorageScope)', + replacement: + 'another scope, or none for the default (`user` on an upload, `global` on a storage ' + + "configuration), and `acl: 'public_read'` on the stored file record of each file that must " + + 'be readable before sign-in (ADR-0104)', + reason: + 'A storage scope never made a file publicly readable. The download doors judge a file by its ' + + '`acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public ' + + 'and the default acl was stored private and needs a signed-in caller, while its scope said ' + + 'otherwise. The value is retired rather than enforced: enforcing it would let any uploader make ' + + "a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for " + + 'anonymous download. Whether a given file must be readable before sign-in is the caller\'s ' + + 'call, so no rewrite can make it: an upload that meant public needs its stored file record ' + + 'marked, and one that did not needs only another scope. The upload request itself carries no ' + + 'acl, and every upload is stored private. Files already stored with scope public are not ' + + 'touched and download exactly as before. ADR-0049', + acceptanceCriteria: + 'No upload call names scope public and no ObjectStorageConfig declares it; each upload that did ' + + 'now names another scope or none and is answered 200. Each file that must render before ' + + "sign-in has acl 'public_read' on its stored file record, and fetching it with no session " + + 'serves it; fetching any other uploaded file with no session is answered 401.', + }, { id: 'strategy-context-aggregation-method-narrowed', surface: 'StrategyContext.executeAggregate aggregations[].method ' From 1ab93430c8eb0a68254c66aa8c03c3d26c245a63 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:52:07 +0000 Subject: [PATCH 3/4] docs(spec): regenerate the storage reference pages for the retired scope public Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN --- content/docs/references/api/storage.mdx | 4 ++-- content/docs/references/system/object-storage.mdx | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/content/docs/references/api/storage.mdx b/content/docs/references/api/storage.mdx index 5c75ff1aa28..dd1e3d133b5 100644 --- a/content/docs/references/api/storage.mdx +++ b/content/docs/references/api/storage.mdx @@ -224,7 +224,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | **filename** | `string` | ✅ | Original filename | | **mimeType** | `string` | ✅ | File MIME type | | **size** | `number` | ✅ | File size in bytes | -| **scope** | `string` | optional (default: `"user"`) | Target storage scope (e.g. user, private, public) | +| **scope** | `string` | optional (default: `"user"`) | Storage scope the new file is filed under (default user; attachments for the record attachments surface). Not an access setting: the upload doors refuse public, and a file is served without sign-in only when its stored file record carries acl 'public_read' (ADR-0104). The upload request carries no acl; every upload is stored private. | | **bucket** | `string` | optional | Specific bucket override (admin only) | @@ -240,7 +240,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | **mimeType** | `string` | ✅ | File MIME type | | **totalSize** | `integer` | ✅ | Total file size in bytes | | **chunkSize** | `integer` | optional (default: `5242880`) | Size of each chunk in bytes (minimum 5MB per S3 spec) | -| **scope** | `string` | optional (default: `"user"`) | Target storage scope | +| **scope** | `string` | optional (default: `"user"`) | Storage scope the new file is filed under (default user; attachments for the record attachments surface). Not an access setting: the upload doors refuse public, and a file is served without sign-in only when its stored file record carries acl 'public_read' (ADR-0104). The upload request carries no acl; every upload is stored private. | | **bucket** | `string` | optional | Specific bucket override (admin only) | | **metadata** | `Record` | optional | Custom metadata key-value pairs | diff --git a/content/docs/references/system/object-storage.mdx b/content/docs/references/system/object-storage.mdx index 24146e49e8b..a0f036909da 100644 --- a/content/docs/references/system/object-storage.mdx +++ b/content/docs/references/system/object-storage.mdx @@ -10,7 +10,7 @@ Object Storage Protocol Unified storage protocol that combines: - Object storage systems (S3, Azure Blob, GCS, MinIO) -- Scoped storage configuration (temp, cache, data, logs, config, public) +- Scoped storage configuration (temp, cache, data, logs, config) - Multi-cloud storage providers - Bucket/container configuration - Access control and permissions @@ -258,7 +258,7 @@ Lifecycle policy action type | **name** | `string` | ✅ | Storage configuration identifier | | **label** | `string` | ✅ | Display label | | **provider** | `Enum<'s3' \| 'azure_blob' \| 'gcs' \| 'minio' \| 'r2' \| 'spaces' \| 'wasabi' \| 'backblaze' \| 'local'>` | ✅ | Primary storage provider | -| **scope** | `Enum<'global' \| 'tenant' \| 'user' \| 'session' \| 'temp' \| 'cache' \| 'data' \| 'logs' \| 'config' \| 'public'>` | optional (default: `"global"`) | Storage scope | +| **scope** | `Enum<'global' \| 'tenant' \| 'user' \| 'session' \| 'temp' \| 'cache' \| 'data' \| 'logs' \| 'config'>` | optional (default: `"global"`) | Storage scope | | **connection** | `{ accessKeyId?: string; secretAccessKey?: string; sessionToken?: string; accountName?: string; … }` | ✅ | Connection credentials | | **buckets** | `{ name: string; label: string; bucketName: string; region?: string; … }[]` | optional (default: `[]`) | Configured buckets | | **defaultBucket** | `string` | optional | Default bucket name for operations | @@ -413,7 +413,6 @@ Storage scope classification * `data` * `logs` * `config` -* `public` --- From cb35f90d4da4c0b96a2b07bbfcd29461d726d98b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 17:11:56 +0000 Subject: [PATCH 4/4] chore(spec): regenerate spec-changes.json and the upgrade guide over the protocol-18 merge Pure regeneration (gen:spec-changes, gen:upgrade-guide) on top of the merge of origin/main 4e9fe9ff6a, which projects step 18 into both documents. The only delta is this branch's D3 semantic entry storage-scope-public-retired: step 17 to 18 migrated 329 to 330, aggregate 16 to 18 migrated 406 to 407, the guide's step-18 semantic list 329 to 330. No hand edit. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 3 +++ packages/spec/spec-changes.json | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index b5a2c6cb727..f72946075bb 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -1299,6 +1299,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - **`startup-orchestrator-retired`** — `the startup-ORCHESTRATION surface of kernel/startup-orchestrator.zod.ts and contracts/startup-orchestrator.ts — 3 emitted defs and 8 exported names: StartupOptionsSchema / StartupOptions / StartupOptionsParsed, HealthStatusSchema / HealthStatus, StartupOrchestrationResultSchema / StartupOrchestrationResult, and the IStartupOrchestrator interface (orchestrateStartup / rollback / checkHealth / startWithTimeout). The startup RESULT survives, re-declared: PluginStartupResultSchema and PluginStartupResult stay on both entries` → (removed — there is no declarative replacement, because nothing ever implemented the interface or parsed the schemas. Plugin startup is the kernel own boot loop: ObjectKernel.start() calls startPluginWithTimeout() per plugin, which races that plugin start() against PluginMetadata.startupTimeout and, when KernelConfig.rollbackOnFailure is set, destroys the already-started plugins and rethrows the original error as the new error cause. So: instead of StartupOptions.timeoutMs declare startupTimeout on the plugin; instead of StartupOptions.rollbackOnFailure set rollbackOnFailure on the kernel config; instead of StartupOrchestrationResult.results read the per-plugin durations through ObjectKernel.getPluginStartupDurations(). StartupOptions.healthCheck and HealthStatus have NO replacement at all — no startup probe system exists, and one returns only through the enforce route of ADR-0049 with a new ADR, the probe first and the vocabulary second. StartupOptions.parallel and StartupOptions.context likewise: the kernel starts plugins sequentially and passes its own PluginContext) - Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06, option 3: keep a startup-result contract re-declared as the shape the kernel ships, and retire the rest. The module declared an orchestration design that never landed, and the spec and the kernel had already drifted into disagreement about the one shape that did: PluginStartupResultSchema described a plugin object, a required durationMs and a health member, while @objectstack/core shipped pluginName, an optional durationMs and timedOut. The ruling keeps a startup-result contract that describes what the kernel actually produces, and retires the rest. Re-measured on this card: zero implementers and zero consumers of the four retired surfaces in this repository and in the pinned objectui checkout, with lit same-corpus controls (defineStack, ManifestSchema); every remaining reference was a generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus are the sharpest of the four: they name a per-plugin health probe the runtime has never had, the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, which an AI author (ADR-0033) reads as proof the capability exists. With no authored document carrying any of the three defs there is no seam for a D2 conversion and no author to tombstone for: route 3, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. The two keys of the SURVIVING result schema that leave (plugin, health) are tombstoned instead, and registered in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is imported by @objectstack/core. A third key arrives on the spec surface only to leave it: core deprecated startTime alias, which held the same elapsed milliseconds as durationMs under a name that promises an instant. The re-declaration had to either mirror it or tombstone it, and mirroring is refused by check:duration-unit-keys (ruling B on duration-shaped number keys: the unit lives in the key name) since it is an elapsed number whose key name carries no unit and matches neither of that rule two schema-declared exemptions. So the L1 window closes here and the kernel stops populating it in the same change. - Done when: No code imports any of the 8 retired names from @objectstack/spec, @objectstack/spec/kernel or @objectstack/spec/contracts — every one is TS2305 after upgrade, pinned by resolved symbol identity in kernel/startup-orchestrator-retirement.test.ts. No metadata document needs editing: none of the three defs was reachable from a metadata-type binding, a stack collection or a manifest embed, so no authored document could ever carry one. PluginStartupResult SURVIVES on both entries with the shape the kernel ships — pluginName, success, optional durationMs, the serializable error projection, timedOut — and @objectstack/core now imports that type instead of declaring a twin, so the drift cannot recur. Writing plugin, health or startTime on a PluginStartupResult is a tsc error and a parse error carrying the rename or the deletion; a reader of the removed startTime alias reads durationMs, which has always carried the same value. Runtime behaviour is unchanged except for that one alias: nothing ever read the retired ORCHESTRATION surfaces, the kernel boot loop is untouched, and the only observable difference is that a startup result no longer carries startTime beside durationMs. +- **`storage-scope-public-retired`** — `the storage scope public — the scope of an upload request, presigned or chunked, and ObjectStorageConfig.scope (StorageScope)` → another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104) + - Why not automatic: A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049 + - Done when: No upload call names scope public and no ObjectStorageConfig declares it; each upload that did now names another scope or none and is answered 200. Each file that must render before sign-in has acl 'public_read' on its stored file record, and fetching it with no session serves it; fetching any other uploaded file with no session is answered 401. - **`strategy-context-aggregation-method-narrowed`** — `StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string` → AggregationFunction (count | sum | avg | min | max | count_distinct, data/query.zod.ts) - the same closed vocabulary IDataEngine.aggregate already declares for the identical slot (AggregationNodeSchema.function; the analytics bridge renames method to function and forwards). A caller filling method from a string-typed value narrows the value to the enum - typing it AggregationFunction, or parsing with the spec's own AggregationFunction zod enum where the value enters from data. Values outside the six were never served: the bridge has parsed-and-refused them at runtime since it stopped declaring its own engine type and began parsing the method with the spec enum, and that refusal stays as defence in depth - Why not automatic: Maintainer ruling 2026-08-28 (option A, census-first): one slot, one declaration. Two spec-declared surfaces described the same value and disagreed about its type: IDataEngine.aggregate's aggregations[].function is the closed six-value AggregationFunction enum while StrategyContext.executeAggregate declared the same slot aggregations[].method: string, so nothing on the analytics side of that seam was compile-checked against the engine's vocabulary - an author, very often an AI (ADR-0033), writing an analytics strategy got no compile-time help and could carry any method name all the way to the bridge's runtime refusal. One slot now has one declaration. Bookkeeping: this is a TYPE narrowing on a runtime TS interface member - no authorable metadata key, no wire shape and no walked-shape def changed, so nothing lands in RETIRED_KEYS_BY_MAJOR / RETIRED_DEFS_BY_MAJOR and the surface ratchets are expected byte-identical. It is a SEMANTIC entry rather than a D2 conversion because there is no authored document or sys_metadata row for the chain to rewrite: the only consumers are TypeScript call sites, and the compile error is the channel that reaches them. In-repo census at the ruling (hard precondition, measured before the narrowing landed): every implementor and every call site filling method is legal under the enum - ObjectQLStrategy.resolveMeasureAggregation emits only the six once it refuses a custom-SQL measure up front, the two literal producers write count, and every test fixture is implementor-side and stays assignable by contravariance. - Done when: External implementors of StrategyContext stay source-compatible: a handler accepting method: string accepts a superset and remains assignable to the narrowed member. External callers filling method with a string-typed or out-of-vocabulary value fail tsc at the executeAggregate call site on upgrade; the fix is narrowing the value's type to AggregationFunction (parsing with the spec enum where it enters from data), never widening a local mirror of the contract. Runtime behaviour is unchanged: the bridge's parse-and-refuse accepts and rejects exactly the same sets before and after, and no stored metadata or document needs editing. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 92f6134c2f1..c8f42c57087 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -2948,6 +2948,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06, option 3: keep a startup-result contract re-declared as the shape the kernel ships, and retire the rest. The module declared an orchestration design that never landed, and the spec and the kernel had already drifted into disagreement about the one shape that did: PluginStartupResultSchema described a plugin object, a required durationMs and a health member, while @objectstack/core shipped pluginName, an optional durationMs and timedOut. The ruling keeps a startup-result contract that describes what the kernel actually produces, and retires the rest. Re-measured on this card: zero implementers and zero consumers of the four retired surfaces in this repository and in the pinned objectui checkout, with lit same-corpus controls (defineStack, ManifestSchema); every remaining reference was a generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus are the sharpest of the four: they name a per-plugin health probe the runtime has never had, the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, which an AI author (ADR-0033) reads as proof the capability exists. With no authored document carrying any of the three defs there is no seam for a D2 conversion and no author to tombstone for: route 3, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. The two keys of the SURVIVING result schema that leave (plugin, health) are tombstoned instead, and registered in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is imported by @objectstack/core. A third key arrives on the spec surface only to leave it: core deprecated startTime alias, which held the same elapsed milliseconds as durationMs under a name that promises an instant. The re-declaration had to either mirror it or tombstone it, and mirroring is refused by check:duration-unit-keys (ruling B on duration-shaped number keys: the unit lives in the key name) since it is an elapsed number whose key name carries no unit and matches neither of that rule two schema-declared exemptions. So the L1 window closes here and the kernel stops populating it in the same change." }, + { + "surface": "the storage scope public — the scope of an upload request, presigned or chunked, and ObjectStorageConfig.scope (StorageScope)", + "replacement": "another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104)", + "migrationId": "storage-scope-public-retired", + "toMajor": 18, + "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049" + }, { "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", "replacement": "AggregationFunction (count | sum | avg | min | max | count_distinct, data/query.zod.ts) - the same closed vocabulary IDataEngine.aggregate already declares for the identical slot (AggregationNodeSchema.function; the analytics bridge renames method to function and forwards). A caller filling method from a string-typed value narrows the value to the enum - typing it AggregationFunction, or parsing with the spec's own AggregationFunction zod enum where the value enters from data. Values outside the six were never served: the bridge has parsed-and-refused them at runtime since it stopped declaring its own engine type and began parsing the method with the spec enum, and that refusal stays as defence in depth", @@ -6537,6 +6544,13 @@ "toMajor": 18, "rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06, option 3: keep a startup-result contract re-declared as the shape the kernel ships, and retire the rest. The module declared an orchestration design that never landed, and the spec and the kernel had already drifted into disagreement about the one shape that did: PluginStartupResultSchema described a plugin object, a required durationMs and a health member, while @objectstack/core shipped pluginName, an optional durationMs and timedOut. The ruling keeps a startup-result contract that describes what the kernel actually produces, and retires the rest. Re-measured on this card: zero implementers and zero consumers of the four retired surfaces in this repository and in the pinned objectui checkout, with lit same-corpus controls (defineStack, ManifestSchema); every remaining reference was a generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus are the sharpest of the four: they name a per-plugin health probe the runtime has never had, the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, which an AI author (ADR-0033) reads as proof the capability exists. With no authored document carrying any of the three defs there is no seam for a D2 conversion and no author to tombstone for: route 3, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. The two keys of the SURVIVING result schema that leave (plugin, health) are tombstoned instead, and registered in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is imported by @objectstack/core. A third key arrives on the spec surface only to leave it: core deprecated startTime alias, which held the same elapsed milliseconds as durationMs under a name that promises an instant. The re-declaration had to either mirror it or tombstone it, and mirroring is refused by check:duration-unit-keys (ruling B on duration-shaped number keys: the unit lives in the key name) since it is an elapsed number whose key name carries no unit and matches neither of that rule two schema-declared exemptions. So the L1 window closes here and the kernel stops populating it in the same change." }, + { + "surface": "the storage scope public — the scope of an upload request, presigned or chunked, and ObjectStorageConfig.scope (StorageScope)", + "replacement": "another scope, or none for the default (`user` on an upload, `global` on a storage configuration), and `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in (ADR-0104)", + "migrationId": "storage-scope-public-retired", + "toMajor": 18, + "rationale": "A storage scope never made a file publicly readable. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps `acl: 'public_read'` the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. Files already stored with scope public are not touched and download exactly as before. ADR-0049" + }, { "surface": "StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string", "replacement": "AggregationFunction (count | sum | avg | min | max | count_distinct, data/query.zod.ts) - the same closed vocabulary IDataEngine.aggregate already declares for the identical slot (AggregationNodeSchema.function; the analytics bridge renames method to function and forwards). A caller filling method from a string-typed value narrows the value to the enum - typing it AggregationFunction, or parsing with the spec's own AggregationFunction zod enum where the value enters from data. Values outside the six were never served: the bridge has parsed-and-refused them at runtime since it stopped declaring its own engine type and began parsing the method with the spec enum, and that refusal stays as defence in depth",