From 647410d1a612908d023bc1c840a57ca3d93285de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:54:53 +0000 Subject: [PATCH 1/6] test(service-storage): pin that concurrent chunk PUTs record every part (red on main) Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../src/chunk-part-record-concurrency.test.ts | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 packages/services/service-storage/src/chunk-part-record-concurrency.test.ts diff --git a/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts b/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts new file mode 100644 index 00000000000..21d02c0a80d --- /dev/null +++ b/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts @@ -0,0 +1,312 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22332] Concurrent chunk PUTs to one upload record every part. +// +// The chunk door reads the session row, merges its part into the parts list +// in memory (`recordChunk`) and writes the record back. Two PUTs to one upload +// at once both read the same record, and the second write replaced the first: +// both answered `200` while `sys_upload_session.parts` kept one part and +// `uploaded_chunks` / `uploaded_size` counted one chunk. Since #22313 the +// completion door refuses such an upload `409` naming the lost chunk, so the +// loss cost a parallel uploader a round trip rather than its file; it was +// still a loss the chunk door reported as a success. +// +// The record's write is now a COMPARE-AND-SET: it lands only while the row's +// progress columns still hold what the door read, and the door re-reads and +// re-merges when another write landed first. On a wired engine that is the +// engine's own conditional update (`where: { id, …guards }, multi: true`, +// which answers the matched-row count); on the engine-absent stand-in it is +// one synchronous compare-and-set on the Map. +// +// Every pin runs on both stores: the stand-in, and a REAL ObjectQL over a REAL +// SqlDriver on sqlite `:memory:` (the project's ruled test backend). + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { LocalStorageAdapter } from './local-storage-adapter.js'; +import { StorageMetadataStore } from './metadata-store.js'; +import type { UploadSessionRecord } from './metadata-store.js'; +import { registerStorageRoutes, CHUNK_RECORD_ATTEMPTS } from './storage-routes.js'; +import { SystemFile } from './objects/system-file.object.js'; +import { SystemUploadSession } from './objects/system-upload-session.object.js'; + +const BASE = '/api/v1/storage'; +const INIT = `${BASE}/upload/chunked`; +const CHUNK = `${BASE}/upload/chunked/:uploadId/chunk/:chunkIndex`; +const COMPLETE = `${BASE}/upload/chunked/:uploadId/complete`; +const PROGRESS = `${BASE}/upload/chunked/:uploadId/progress`; + +/** The chunk size the init door floors every upload at (5 MiB). */ +const MIN_CHUNK = 5 * 1024 * 1024; + +type Res = IHttpResponse & { _status: number; _json: any }; +type StoredPart = { chunkIndex: number; eTag: string; size: number }; + +function createMockHttpServer() { + const routes = new Map(); + return { + get: vi.fn((path: string, handler: RouteHandler) => { routes.set(`GET:${path}`, handler); }), + post: vi.fn((path: string, handler: RouteHandler) => { routes.set(`POST:${path}`, handler); }), + put: vi.fn((path: string, handler: RouteHandler) => { routes.set(`PUT:${path}`, handler); }), + delete: vi.fn(), + patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + handler(method: string, path: string): RouteHandler { + const h = routes.get(`${method}:${path}`); + if (!h) throw new Error(`no route ${method} ${path}`); + return h; + }, + }; +} + +function createMockReq(overrides: Partial & Record = {}): IHttpRequest { + return { params: {}, query: {}, body: undefined, headers: {}, method: 'GET', path: '/', ...overrides } as IHttpRequest; +} + +function createMockRes(): Res { + const res: any = { + _status: 200, + _json: null, + json(data: any) { res._json = data; }, + send(data: any) { res._sent = data; }, + status(code: number) { res._status = code; return res; }, + header() { return res; }, + }; + return res; +} + +// ── the two stores ───────────────────────────────────────────────────────── + +interface Backend { + store: StorageMetadataStore; + /** `sys_upload_session` writes that reached the engine — `null` on the stand-in, which has none. */ + sessionWrites(): number | null; + teardown(): Promise; +} + +async function standIn(): Promise { + return { store: new StorageMetadataStore(null), sessionWrites: () => null, teardown: async () => {} }; +} + +async function realEngine(): Promise { + const sql = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const ql = new ObjectQL(); + ql.registerDriver(sql, true); + await ql.init(); + ql.registry.registerObject(SystemFile as any, 'com.objectstack.storage'); + ql.registry.registerObject(SystemUploadSession as any, 'com.objectstack.storage'); + await ql.syncSchemas(); + let writes = 0; + const realUpdate = ql.update.bind(ql); + (ql as any).update = (object: string, data: any, options: any) => { + if (object === 'sys_upload_session') writes++; + return realUpdate(object, data, options); + }; + return { + store: new StorageMetadataStore(ql as any), + sessionWrites: () => writes, + teardown: async () => { + try { + await ql.destroy(); + } catch { + /* noop */ + } + }, + }; +} + +describe.each([ + ['the engine-absent stand-in', standIn], + ['a real ObjectQL over SqlDriver (sqlite :memory:)', realEngine], +] as const)('[#22332] the chunk door records every part — %s', (_label, makeBackend) => { + let rootDir: string; + let adapter: LocalStorageAdapter; + let backend: Backend; + let store: StorageMetadataStore; + let server: ReturnType; + + beforeEach(async () => { + rootDir = join(tmpdir(), `os-chunk-cas-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await fs.mkdir(rootDir, { recursive: true }); + adapter = new LocalStorageAdapter({ rootDir, signingSecret: 'test-secret' }); + backend = await makeBackend(); + store = backend.store; + server = createMockHttpServer(); + registerStorageRoutes(server as any, adapter, store, { basePath: BASE }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await backend.teardown(); + await fs.rm(rootDir, { recursive: true, force: true }); + }); + + async function init(totalSize: number) { + const res = createMockRes(); + await server.handler('POST', INIT)( + createMockReq({ body: { filename: 'big.bin', mimeType: 'application/octet-stream', totalSize } }), + res, + ); + expect(res._status, JSON.stringify(res._json)).toBe(200); + return res._json.data as { uploadId: string; resumeToken: string; fileId: string; totalChunks: number }; + } + + async function putChunk(uploadId: string, resumeToken: string, chunkIndex: number, bytes: Buffer): Promise { + const res = createMockRes(); + await server.handler('PUT', CHUNK)( + createMockReq({ + params: { uploadId, chunkIndex: String(chunkIndex) }, + headers: { 'x-resume-token': resumeToken }, + rawBody: async () => bytes, + }), + res, + ); + return res; + } + + async function progress(uploadId: string) { + const res = createMockRes(); + await server.handler('GET', PROGRESS)(createMockReq({ params: { uploadId } }), res); + expect(res._status, JSON.stringify(res._json)).toBe(200); + return res._json.data as { uploadedSize: number; uploadedChunks: number }; + } + + async function storedParts(uploadId: string): Promise<{ row: UploadSessionRecord; parts: StoredPart[] }> { + const row = (await store.getSession(uploadId))!; + const parts = (JSON.parse(row.parts ?? '[]') as StoredPart[]).sort((a, b) => a.chunkIndex - b.chunkIndex); + return { row, parts }; + } + + /** Chunk `i` of a test upload: distinct bytes and a distinct size, so a lost part shows in every count. */ + const chunkBytes = (i: number) => Buffer.alloc(10 + i, 0x61 + i); + + it.each([2, 8])('%i concurrent chunk PUTs to one upload each record their part', async (n) => { + const { uploadId, resumeToken, totalChunks } = await init(n * MIN_CHUNK); + expect(totalChunks).toBe(n); + const chunks = Array.from({ length: n }, (_, i) => chunkBytes(i)); + + const answers = await Promise.all(chunks.map((bytes, i) => putChunk(uploadId, resumeToken, i, bytes))); + + for (const res of answers) expect(res._status, JSON.stringify(res._json)).toBe(200); + const { row, parts } = await storedParts(uploadId); + expect(parts.map((p) => p.chunkIndex), 'every concurrently sent chunk is in the record').toEqual( + Array.from({ length: n }, (_, i) => i), + ); + expect(parts.map((p) => p.eTag)).toEqual(answers.map((res) => res._json.data.eTag)); + expect(parts.map((p) => p.size)).toEqual(chunks.map((c) => c.byteLength)); + const total = chunks.reduce((sum, c) => sum + c.byteLength, 0); + expect(row.uploaded_chunks).toBe(n); + expect(row.uploaded_size).toBe(total); + expect(await progress(uploadId)).toMatchObject({ uploadedChunks: n, uploadedSize: total }); + }); + + it('a parallel upload completes on its first completion — no lost chunk to send again', async () => { + const chunk0 = Buffer.alloc(MIN_CHUNK, 0x61); + const chunk1 = Buffer.alloc(10, 0x62); + const { uploadId, resumeToken } = await init(MIN_CHUNK + 10); + + const [sent0, sent1] = await Promise.all([ + putChunk(uploadId, resumeToken, 0, chunk0), + putChunk(uploadId, resumeToken, 1, chunk1), + ]); + const res = createMockRes(); + await server.handler('POST', COMPLETE)( + createMockReq({ + params: { uploadId }, + body: { + uploadId, + parts: [ + { chunkIndex: 0, eTag: sent0._json.data.eTag }, + { chunkIndex: 1, eTag: sent1._json.data.eTag }, + ], + }, + }), + res, + ); + + expect(res._status, JSON.stringify(res._json)).toBe(200); + expect(res._json.data.size).toBe(MIN_CHUNK + 10); + expect((await adapter.download(res._json.data.key)).equals(Buffer.concat([chunk0, chunk1]))).toBe(true); + }); + + it('CONTROL: the sequential path is unchanged — the same answers, the same record, one write per chunk', async () => { + const { uploadId, resumeToken } = await init(3 * MIN_CHUNK); + const writesBefore = backend.sessionWrites(); + + const answers: unknown[] = []; + for (let i = 0; i < 3; i++) { + const res = await putChunk(uploadId, resumeToken, i, chunkBytes(i)); + expect(res._status, JSON.stringify(res._json)).toBe(200); + answers.push(res._json); + } + + expect(answers.map((a: any) => ({ ...a.data, eTag: typeof a.data.eTag }))).toEqual([ + { chunkIndex: 0, eTag: 'string', bytesReceived: 10 }, + { chunkIndex: 1, eTag: 'string', bytesReceived: 11 }, + { chunkIndex: 2, eTag: 'string', bytesReceived: 12 }, + ]); + // An uncontended write lands first time: no retry reaches the engine. + const writesAfter = backend.sessionWrites(); + if (writesBefore !== null) expect(writesAfter! - writesBefore).toBe(3); + const { row, parts } = await storedParts(uploadId); + expect(parts.map((p) => [p.chunkIndex, p.size])).toEqual([[0, 10], [1, 11], [2, 12]]); + expect(row.uploaded_chunks).toBe(3); + expect(row.uploaded_size).toBe(33); + expect(await progress(uploadId)).toMatchObject({ uploadedChunks: 3, uploadedSize: 33 }); + }); + + it(`REFUSED 409 RESOURCE_CONFLICT when another write lands before each of ${CHUNK_RECORD_ATTEMPTS} attempts — the other writes kept, nothing silently lost`, async () => { + const { uploadId, resumeToken } = await init(2 * MIN_CHUNK); + // A competitor lands a part between EVERY read the chunk door makes and the + // write that follows it: the read hands back the row as it stood, then the + // competitor's own write moves it on. + const readSession = store.getSession.bind(store); + let competitors = 0; + let competing = false; + let doorReads = 0; + vi.spyOn(store, 'getSession').mockImplementation(async (id: string) => { + // The stand-in's own `updateSession` reads through `getSession`: the + // competitor's write must not count as a door read or compete again. + if (competing) return readSession(id); + doorReads++; + const row = await readSession(id); + if (row) { + const held = JSON.parse(row.parts ?? '[]') as StoredPart[]; + const part: StoredPart = { chunkIndex: 100 + competitors, eTag: `competitor-${competitors}`, size: 1 }; + competitors++; + competing = true; + try { + await store.updateSession(id, { + parts: JSON.stringify([...held, part]), + uploaded_chunks: held.length + 1, + uploaded_size: (row.uploaded_size ?? 0) + 1, + }); + } finally { + competing = false; + } + } + return row; + }); + + const res = await putChunk(uploadId, resumeToken, 0, chunkBytes(0)); + + expect.soft(res._status, JSON.stringify(res._json)).toBe(409); + expect.soft(res._json.success).toBe(false); + expect.soft(res._json.error?.code).toBe('RESOURCE_CONFLICT'); + expect.soft(res._json.error?.details).toEqual({ chunkIndex: 0, attempts: CHUNK_RECORD_ATTEMPTS }); + expect.soft(doorReads, 'one read per attempt: the first, then one re-read per lost write').toBe(CHUNK_RECORD_ATTEMPTS); + vi.mocked(store.getSession).mockRestore(); + // Every competitor's part survived, and the refused chunk is not claimed as held. + const { parts } = await storedParts(uploadId); + expect(parts.map((p) => p.chunkIndex)).toEqual( + Array.from({ length: CHUNK_RECORD_ATTEMPTS }, (_, i) => 100 + i), + ); + }); +}); From cabbd740c65f6bac7379bd1f7a2b812cd72f883a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:58:45 +0000 Subject: [PATCH 2/6] fix(service-storage): the chunk door records its part with a compare-and-set, so concurrent chunk PUTs record every part Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../src/chunk-part-record-concurrency.test.ts | 189 +++++++++++++++++- .../service-storage/src/metadata-store.ts | 131 ++++++++++++ .../service-storage/src/storage-routes.ts | 102 ++++++++-- 3 files changed, 406 insertions(+), 16 deletions(-) diff --git a/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts b/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts index 21d02c0a80d..c90915bf60c 100644 --- a/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts +++ b/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts @@ -25,12 +25,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { ObjectQL } from '@objectstack/objectql'; +import { ObjectQL, assertEngineUpdateDispatch, resolveEngineUpdateDispatch } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; import { LocalStorageAdapter } from './local-storage-adapter.js'; -import { StorageMetadataStore } from './metadata-store.js'; -import type { UploadSessionRecord } from './metadata-store.js'; +import { StorageMetadataStore, updateSessionProgressIfUnchanged } from './metadata-store.js'; +import type { UploadSessionProgress, UploadSessionRecord } from './metadata-store.js'; import { registerStorageRoutes, CHUNK_RECORD_ATTEMPTS } from './storage-routes.js'; import { SystemFile } from './objects/system-file.object.js'; import { SystemUploadSession } from './objects/system-upload-session.object.js'; @@ -86,13 +86,15 @@ function createMockRes(): Res { interface Backend { store: StorageMetadataStore; + /** The real engine, when there is one. */ + engine: any; /** `sys_upload_session` writes that reached the engine — `null` on the stand-in, which has none. */ sessionWrites(): number | null; teardown(): Promise; } async function standIn(): Promise { - return { store: new StorageMetadataStore(null), sessionWrites: () => null, teardown: async () => {} }; + return { store: new StorageMetadataStore(null), engine: null, sessionWrites: () => null, teardown: async () => {} }; } async function realEngine(): Promise { @@ -111,6 +113,7 @@ async function realEngine(): Promise { }; return { store: new StorageMetadataStore(ql as any), + engine: ql, sessionWrites: () => writes, teardown: async () => { try { @@ -310,3 +313,181 @@ describe.each([ ); }); }); + +// ── the store's conditional progress write ───────────────────────────────── + +const sessionRec = (id: string): UploadSessionRecord => ({ + id, + file_id: `f_${id}`, + key: `user/${id}.bin`, + filename: `${id}.bin`, + total_size: 20, + chunk_size: 10, + total_chunks: 2, + status: 'in_progress', +}); + +const ONE_PART: UploadSessionProgress = { + parts: JSON.stringify([{ chunkIndex: 0, eTag: 'e0', size: 10 }]), + uploaded_chunks: 1, + uploaded_size: 10, +}; + +describe.each([ + ['the engine-absent stand-in', standIn], + ['a real ObjectQL over SqlDriver (sqlite :memory:)', realEngine], +] as const)('[#22332] updateSessionProgressIfUnchanged — %s', (_label, makeBackend) => { + let backend: Backend; + let store: StorageMetadataStore; + + beforeEach(async () => { + backend = await makeBackend(); + store = backend.store; + await store.createSession(sessionRec('s1')); + }); + + afterEach(async () => { + await backend.teardown(); + }); + + it('lands on a row that still holds the progress the caller read', async () => { + const seen = (await store.getSession('s1'))!; + expect(await updateSessionProgressIfUnchanged(store, 's1', seen, ONE_PART)).toBe(true); + expect(await store.getSession('s1')).toMatchObject(ONE_PART); + }); + + it.each([ + ['parts', { parts: '[{"chunkIndex":9,"eTag":"x","size":1}]' }], + ['uploaded_chunks', { uploaded_chunks: 7 }], + ['uploaded_size', { uploaded_size: 7 }], + ] as const)('REFUSED once %s moved since the read — the row keeps the other write', async (_column, moved) => { + const seen = (await store.getSession('s1'))!; + await store.updateSession('s1', moved); + + expect(await updateSessionProgressIfUnchanged(store, 's1', seen, ONE_PART)).toBe(false); + expect(await store.getSession('s1')).toMatchObject(moved); + }); + + it('REFUSED for a row that is gone', async () => { + const seen = (await store.getSession('s1'))!; + await store.deleteSession('s1'); + expect(await updateSessionProgressIfUnchanged(store, 's1', seen, ONE_PART)).toBe(false); + expect(await store.getSession('s1')).toBeNull(); + }); +}); + +describe('[#22332] updateSessionProgressIfUnchanged on a wired engine is the engine’s conditional update', () => { + let backend: Backend; + let calls: Array<{ data: any; options: any; dispatch: string; answer: unknown }>; + + beforeEach(async () => { + backend = await realEngine(); + const ql = backend.engine; + const inner = ql.update; + calls = []; + ql.update = async (object: string, data: any, options: any) => { + // Snapshotted before the call: the engine may decorate what it is handed. + const asked = { data: structuredClone(data), options: structuredClone(options) }; + const answer = await inner(object, data, options); + if (object === 'sys_upload_session' && options?.multi) { + assertEngineUpdateDispatch(asked.data, asked.options); + calls.push({ ...asked, dispatch: resolveEngineUpdateDispatch(asked.data, asked.options).kind, answer }); + } + return answer; + }; + }); + + afterEach(async () => { + await backend.teardown(); + }); + + it('the guard rides the `where` beside the id, as a declared predicate, scoped like every by-id write', async () => { + const { store } = backend; + await store.createSession(sessionRec('s1'), { organizationId: 'org_A' }); + const seen = (await store.getSession('s1'))!; + + expect(await updateSessionProgressIfUnchanged(store, 's1', seen, ONE_PART, { organizationId: 'org_A' })).toBe(true); + + expect(calls).toEqual([ + { + data: ONE_PART, + options: { + where: { parts: '[]', uploaded_chunks: 0, uploaded_size: 0, id: 's1' }, + multi: true, + context: { tenantId: 'org_A', isSystem: true }, + }, + dispatch: 'multi', + answer: 1, + }, + ]); + }); + + it('a row stamped for another organization matches nothing — the write misses, it does not land there', async () => { + const { store } = backend; + await store.createSession(sessionRec('s1'), { organizationId: 'org_B' }); + const seen = (await store.getSession('s1'))!; + + expect(await updateSessionProgressIfUnchanged(store, 's1', seen, ONE_PART, { organizationId: 'org_A' })).toBe(false); + expect((await store.getSession('s1'))!.parts).toBe('[]'); + }); + + it('REFUSED loudly when the engine answers something other than a matched-row count', async () => { + const { store } = backend; + await store.createSession(sessionRec('s1')); + const seen = (await store.getSession('s1'))!; + backend.engine.update = async (_o: string, data: any) => ({ ...data }); + + await expect(updateSessionProgressIfUnchanged(store, 's1', seen, ONE_PART)).rejects.toMatchObject({ + name: 'StorageMetadataStoreError', + objectName: 'sys_upload_session', + operation: 'update', + message: expect.stringContaining('where a matched-row count was due'), + }); + }); + + it('the chunk door answers 500, once, when its write cannot reach a row nothing else moved', async () => { + const rootDir = join(tmpdir(), `os-chunk-cas-unreachable-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await fs.mkdir(rootDir, { recursive: true }); + try { + const server = createMockHttpServer(); + registerStorageRoutes(server as any, new LocalStorageAdapter({ rootDir, signingSecret: 'test-secret' }), backend.store, { + basePath: BASE, + }); + const initRes = createMockRes(); + await server.handler('POST', INIT)( + createMockReq({ body: { filename: 'u.bin', mimeType: 'application/octet-stream', totalSize: 10 } }), + initRes, + ); + expect(initRes._status, JSON.stringify(initRes._json)).toBe(200); + const { uploadId, resumeToken } = initRes._json.data; + // The conditional write matches no row and changes nothing, whatever it is asked. + const inner = backend.engine.update; + let conditional = 0; + backend.engine.update = async (object: string, data: any, options: any) => { + if (object === 'sys_upload_session' && options?.multi) { + conditional++; + return 0; + } + return inner(object, data, options); + }; + + const res = createMockRes(); + await server.handler('PUT', CHUNK)( + createMockReq({ + params: { uploadId, chunkIndex: '0' }, + headers: { 'x-resume-token': resumeToken }, + rawBody: async () => Buffer.alloc(10, 0x61), + }), + res, + ); + + expect(res._status, JSON.stringify(res._json)).toBe(500); + expect(res._json.error.code).toBe('INTERNAL'); + expect(res._json.error.message).toContain('the write cannot reach the row'); + expect(conditional, 'no retry loop over a write that cannot land').toBe(1); + expect((await backend.store.getSession(uploadId))!.parts).toBe('[]'); + } finally { + await fs.rm(rootDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/services/service-storage/src/metadata-store.ts b/packages/services/service-storage/src/metadata-store.ts index 065ddd1d0ca..4e4c9641e52 100644 --- a/packages/services/service-storage/src/metadata-store.ts +++ b/packages/services/service-storage/src/metadata-store.ts @@ -362,6 +362,45 @@ const SESSION_READ_CONSEQUENCE = */ const ENGINE_BACKED_STORES = new WeakSet(); +/** + * [#22332] The progress a `sys_upload_session` row records: every chunk the + * upload holds (`parts`) and the two counts derived from it. The chunk door + * reads these, merges its chunk in, and writes all three back together. + */ +export type UploadSessionProgress = Required< + Pick +>; + +/** A store's conditional progress write — see {@link updateSessionProgressIfUnchanged}. */ +type ConditionalProgressWrite = ( + id: string, + seen: UploadSessionRecord, + progress: UploadSessionProgress, + context?: StorageWriteContext, +) => Promise; + +/** + * [#22332] Each store's conditional progress write, keyed by store — the + * {@link ENGINE_BACKED_STORES} pattern: module-private, so the chunk door can + * reach it through {@link updateSessionProgressIfUnchanged} without the + * class's public face growing a method. + */ +const CONDITIONAL_PROGRESS_WRITES = new WeakMap(); + +/** + * [#22332] The progress columns `seen` held, as the guard a conditional write + * compares against. A column the row did not carry is `null` in the guard — + * never `undefined`, which a `where` would drop, silently removing that term + * from the comparison. + */ +function progressGuard(seen: UploadSessionRecord): { [K in keyof UploadSessionProgress]: UploadSessionProgress[K] | null } { + return { + parts: seen.parts ?? null, + uploaded_chunks: seen.uploaded_chunks ?? null, + uploaded_size: seen.uploaded_size ?? null, + }; +} + /** * Storage metadata persistence. * @@ -393,6 +432,9 @@ export class StorageMetadataStore { constructor(private readonly engine: IDataEngine | null) { if (engine) ENGINE_BACKED_STORES.add(this); + CONDITIONAL_PROGRESS_WRITES.set(this, (id, seen, progress, context) => + this.updateSessionProgressIfUnchanged(id, seen, progress, context), + ); } /** @@ -646,6 +688,63 @@ export class StorageMetadataStore { return merged; } + /** + * [#22332] Write `progress` to one `sys_upload_session` row only while the + * row's progress columns still hold what `seen` held — a compare-and-set. + * `true` when the write landed; `false` when it did not, because another + * write moved the progress first or the row is no longer there. + * + * {@link updateSession} cannot serve the chunk door: it writes whatever it + * is handed, so two chunk PUTs that read the same record each wrote their + * own merge of it back and the later write erased the earlier chunk. + * + * On a wired engine this is the engine's own conditional update: the guard + * rides the `where` beside the id and `multi: true` declares the predicate + * (the compare-and-set spelling of `resolveEngineUpdateDispatch`), so the + * driver evaluates it in the same statement that writes, and the call + * answers the matched-row count. Same opt-in and same scope as + * {@link updateSession}: a row out of the acting organization's reach + * matches nothing. On the engine-absent stand-in it is one synchronous + * compare-and-set on the Map, which no other call can interleave with. + */ + private async updateSessionProgressIfUnchanged( + id: string, + seen: UploadSessionRecord, + progress: UploadSessionProgress, + context?: StorageWriteContext, + ): Promise { + const guard = progressGuard(seen); + if (!this.engine) { + // Same reasoning as `updateSession`'s stand-in branch: no reach to enforce. + const row = this.sessions.get(id); + if (!row) return false; + const held = progressGuard(row); + if (held.parts !== guard.parts || held.uploaded_chunks !== guard.uploaded_chunks || held.uploaded_size !== guard.uploaded_size) { + return false; + } + this.sessions.set(id, { ...row, ...progress, id, updated_at: new Date().toISOString() }); + return true; + } + const matched = await this.engineOp('sys_upload_session', 'update', SESSION_UPDATE_CONSEQUENCE, async (engine) => { + const answer = await engine.update( + 'sys_upload_session', + { ...progress }, + { where: { ...guard, id }, multi: true, ...systemByIdWriteOptionsFor(context) }, + ); + // A predicate update answers the matched-row count (`IDataEngine.update`); + // anything else leaves the write's outcome unknown, so it is not read + // as either one. + if (typeof answer !== 'number') { + throw new Error( + `the conditional update answered ${answer === null ? 'null' : `a value of type ${typeof answer}`} where a matched-row count was due, ` + + 'so whether the progress landed is unknown', + ); + } + return answer; + }); + return matched > 0; + } + /** * Delete one `sys_upload_session` row. * @@ -704,3 +803,35 @@ export function organizationOutOfWriteReach( if (typeof started !== 'string' || started.length === 0) return null; return started === acting ? null : started; } + +/** + * [#22332] Write `progress` to the `sys_upload_session` row `id` only while + * its progress columns (`parts`, `uploaded_chunks`, `uploaded_size`) still + * hold what `seen` — the row as the caller read it — held. `true` when the + * write landed; `false` when another write moved the progress first or the + * row is gone, and the caller re-reads and merges again. + * + * The chunk door's progress write: it merges its chunk into the record it + * read, so a write that does not check the record is unchanged erases every + * chunk a concurrent PUT recorded in between. See the store's + * `updateSessionProgressIfUnchanged` for how each store makes the comparison + * and the write one step. + * + * ⛔ Not re-exported from the package entry, like + * {@link organizationOutOfWriteReach}: it is the chunk door's write, not API. + */ +export function updateSessionProgressIfUnchanged( + store: StorageMetadataStore, + id: string, + seen: UploadSessionRecord, + progress: UploadSessionProgress, + context?: StorageWriteContext, +): Promise { + const write = CONDITIONAL_PROGRESS_WRITES.get(store); + if (!write) { + throw new TypeError( + 'updateSessionProgressIfUnchanged: the store was not constructed by StorageMetadataStore, so it has no conditional progress write', + ); + } + return write(id, seen, progress, context); +} diff --git a/packages/services/service-storage/src/storage-routes.ts b/packages/services/service-storage/src/storage-routes.ts index 2576ee00b73..920b7add1e8 100644 --- a/packages/services/service-storage/src/storage-routes.ts +++ b/packages/services/service-storage/src/storage-routes.ts @@ -18,8 +18,9 @@ import type { StorageWriteContext, } from './metadata-store.js'; // [#22175] Whether a door's scoped by-id write can reach the row it names — -// the store's own answer, asked before the write. -import { organizationOutOfWriteReach } from './metadata-store.js'; +// the store's own answer, asked before the write. [#22332] And the chunk +// door's conditional progress write. +import { organizationOutOfWriteReach, updateSessionProgressIfUnchanged } from './metadata-store.js'; import type { LocalStorageAdapter } from './local-storage-adapter.js'; // [#22046] The ONE upload ownership rule. Declared in its own module and only // CALLED here — the three doors below share it rather than each carrying a @@ -114,6 +115,27 @@ function uploadTooLargeMessage(measured: string, maxUploadBytes: number): string const INCOMPLETE_UPLOAD_STATUS = 409; const INCOMPLETE_UPLOAD_CODE: StandardErrorCode = 'RESOURCE_CONFLICT'; +/** + * [#22332] How many times the chunk door tries to record a stored chunk in the + * upload's progress before it refuses. + * + * The record is written with a compare-and-set + * (`updateSessionProgressIfUnchanged`), and a write only loses to a write + * that landed between the door's read and its own — so each lost attempt is + * another chunk recorded, and a chunk loses at most once per chunk recorded + * alongside it. Sixteen covers parallel uploaders well beyond the four to ten + * lanes they commonly run; past it the door refuses rather than spin. + * + * The refusal is `409` / `RESOURCE_CONFLICT` (ADR-0112's standard member for + * HTTP 409, the code the completion door answers too): the chunk's bytes are + * stored but the upload does not record it, and sending the chunk again + * resolves it — a re-sent chunk replaces its slot. ⛔ Never a `200`: a chunk + * answered as stored and missing from the record is the loss this guards. + */ +export const CHUNK_RECORD_ATTEMPTS = 16; +const CHUNK_NOT_RECORDED_STATUS = 409; +const CHUNK_NOT_RECORDED_CODE: StandardErrorCode = 'RESOURCE_CONFLICT'; + /** * The request's declared `content-length`, or `undefined` when it carries none * that parses. A pre-check only, so an oversized body is refused before it is @@ -945,16 +967,60 @@ export function registerStorageRoutes( // replaces its bytes in the backend, so a retry is counted once in // `uploaded_chunks` and `uploaded_size` — the counts `resumeUpload` // resumes from (see `recordChunk`). - const progress = recordChunk(currentParts, { chunkIndex, eTag, size: data.byteLength }, session.uploaded_size ?? 0); - await store.updateSession( - uploadId, - { - uploaded_chunks: progress.uploadedChunks, - uploaded_size: progress.uploadedSize, - parts: JSON.stringify(progress.parts), - }, - writeContext, - ); + // + // [#22332] The record is merged from the row this door READ, so it is + // written only while the row still holds that progress: a chunk PUT + // running alongside this one may have recorded its part in between, and + // an unconditional write erased it — both answered `200` and the upload + // held one chunk. When the write does not land, the door re-reads the + // row and merges again, up to CHUNK_RECORD_ATTEMPTS. + const part = { chunkIndex, eTag, size: data.byteLength }; + let seen = session; + let parts = currentParts; + for (let attempt = 1; ; attempt++) { + const progress = recordChunk(parts, part, seen.uploaded_size ?? 0); + const landed = await updateSessionProgressIfUnchanged( + store, + uploadId, + seen, + { + uploaded_chunks: progress.uploadedChunks, + uploaded_size: progress.uploadedSize, + parts: JSON.stringify(progress.parts), + }, + writeContext, + ); + if (landed) break; + if (attempt >= CHUNK_RECORD_ATTEMPTS) { + sendError( + res, + CHUNK_NOT_RECORDED_STATUS, + CHUNK_NOT_RECORDED_CODE, + `Chunk ${chunkIndex} was stored, but recording it in the upload's progress lost to another write on each of ` + + `${CHUNK_RECORD_ATTEMPTS} attempts, so the upload does not hold it: send this chunk again.`, + { details: { chunkIndex, attempts: CHUNK_RECORD_ATTEMPTS } }, + ); + return; + } + const fresh = await store.getSession(uploadId); + if (!fresh) { + sendError(res, 404, 'UPLOAD_SESSION_NOT_FOUND', 'Upload session not found'); + return; + } + // A write that did not land on a row still holding the progress it + // was conditioned on lost to no other write: it cannot reach the row. + // Trying again would answer the same, and a `409` would tell the + // uploader to retry what cannot succeed. + if (sameProgress(fresh, seen)) { + throw new Error( + `Chunk ${chunkIndex} was stored, but its progress write to upload session '${uploadId}' matched no row ` + + 'although the row still holds the progress the write was conditioned on: the write cannot reach the ' + + 'row, and the upload does not hold the chunk.', + ); + } + seen = fresh; + parts = JSON.parse(fresh.parts ?? '[]'); + } sendOk(res, { chunkIndex, @@ -1376,6 +1442,18 @@ function recordChunk( }; } +/** + * [#22332] Whether two reads of one session row hold the same progress — the + * three columns the chunk door's conditional write is compared on. + */ +function sameProgress(a: UploadSessionRecord, b: UploadSessionRecord): boolean { + return ( + (a.parts ?? null) === (b.parts ?? null) && + (a.uploaded_chunks ?? null) === (b.uploaded_chunks ?? null) && + (a.uploaded_size ?? null) === (b.uploaded_size ?? null) + ); +} + /** One entry of a completion request's `parts` list. */ interface ListedPart { chunkIndex: number; From 82a3a92e5f2af45f47d6a024d9aee0fa74ed7e79 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:01:30 +0000 Subject: [PATCH 3/6] test(service-storage): the fake engines answer a declared predicate update with its matched-row count Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../storage-routes.metadata-outage.test.ts | 13 +++++++++++ ...t-audit-update-delete-half-repairs.test.ts | 23 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/services/service-storage/src/storage-routes.metadata-outage.test.ts b/packages/services/service-storage/src/storage-routes.metadata-outage.test.ts index ce42f43f41d..0c177be9ed7 100644 --- a/packages/services/service-storage/src/storage-routes.metadata-outage.test.ts +++ b/packages/services/service-storage/src/storage-routes.metadata-outage.test.ts @@ -93,6 +93,19 @@ function createFakeEngine(opts: { failing?: EngineMethod[] } = {}) { boom('update', object); const id = String(options?.where?.id ?? data?.id); const t = table(object); + // [#22332] A declared predicate update — the chunk door's conditional + // progress write — lands only on a row holding every `where` term (an + // absent column holds `null`) and answers the matched-row count, as the + // real engine's does. + if (options?.multi) { + const row = t.get(id); + const holds = + row !== undefined && + Object.entries(options.where ?? {}).every(([k, v]) => k === 'id' || (row[k] ?? null) === (v ?? null)); + if (!holds) return 0; + t.set(id, { ...row, ...data }); + return 1; + } if (!t.has(id)) return null; t.set(id, { ...t.get(id), ...data }); return { ...t.get(id) }; diff --git a/packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts b/packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts index 23ce9fa0e0e..d9473771d4f 100644 --- a/packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts +++ b/packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts @@ -130,6 +130,21 @@ function createRecordingEngine(seed: Array<{ object: string; data: any }> = []) async update(object: string, data: any, options?: any) { assertEngineUpdateDispatch(data, options); updates.push({ object, data: { ...data }, options }); + // [#22332] A declared predicate update — the chunk door's conditional + // progress write — lands on the rows that hold every `where` term (an + // absent column holds `null`) and answers the matched-row count, as the + // real engine's does. + if (options?.multi) { + const matched = rows.filter( + (r) => + r.object === object && + Object.entries(options.where ?? {}).every(([k, v]) => + k === 'id' ? String(r.data.id) === String(v) : (r.data[k] ?? null) === (v ?? null), + ), + ); + for (const r of matched) Object.assign(r.data, data); + return matched.length; + } // [#22313] A by-id update lands on the row, as it does in the real // engine: the chunked completion reads back the record the chunk door // wrote, and assembles only an upload that holds its declared bytes. @@ -455,7 +470,13 @@ describe('[#13178] the routes: the upload doors pass the session organization on const update = engine._updates.find((u: any) => u.object === 'sys_upload_session'); expect(update).toBeTruthy(); - expect(update.options).toEqual({ where: { id: 's1' }, context: { tenantId: 'org_A', isSystem: true } }); + // [#22332] The progress write is conditional on the progress the door + // read — here a seeded row carrying none — and scoped exactly as before. + expect(update.options).toEqual({ + where: { parts: null, uploaded_chunks: null, uploaded_size: null, id: 's1' }, + multi: true, + context: { tenantId: 'org_A', isSystem: true }, + }); }); it('the progress door — which WRITES when it expires a row — scopes that write too', async () => { From 288ee85dade3cd5afe0ab6ed11ffef34ff20db5c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:06:29 +0000 Subject: [PATCH 4/6] test(dogfood): parallel chunk PUTs each record their part over a real boot Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- ...age-chunked-parallel-parts.dogfood.test.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 packages/qa/dogfood/test/storage-chunked-parallel-parts.dogfood.test.ts diff --git a/packages/qa/dogfood/test/storage-chunked-parallel-parts.dogfood.test.ts b/packages/qa/dogfood/test/storage-chunked-parallel-parts.dogfood.test.ts new file mode 100644 index 00000000000..43642ba5053 --- /dev/null +++ b/packages/qa/dogfood/test/storage-chunked-parallel-parts.dogfood.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22332] Chunk PUTs sent in parallel to one upload each record their part — +// over the HTTP doors of a REAL boot. +// +// The package suite (`service-storage/src/chunk-part-record-concurrency.test.ts`) +// pins the rule at the route handlers, over the engine-absent stand-in and a +// real ObjectQL on SqlDriver. This file sends the chunks the way a parallel +// (S3-style) uploader does — every PUT in flight at once — through the booted +// stack's own engine and driver, then reads the progress back and completes the +// upload listing every part. Before the fix both PUTs answered `200`, the +// progress counted one chunk, and the completion was refused `409` naming the +// chunk the record had lost. +// +// Not eligible for the shared showcase project: it boots its own storage root. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { StorageServicePlugin } from '@objectstack/service-storage'; +import { attachmentsFixtureStack, attachmentsFixtureSecurity } from './fixtures/attachments-fixture.js'; + +const MIB = 1024 * 1024; +/** The chunk size the init door floors every upload at. */ +const MIN_CHUNK = 5 * MIB; + +describe('[#22332] parallel chunk PUTs each record their part, over a real boot', () => { + let stack: VerifyStack; + let token: string; + + const json = { 'Content-Type': 'application/json' }; + const auth = () => ({ Authorization: `Bearer ${token}` }); + + const init = async (totalSize: number) => { + const res = await stack.api('/storage/upload/chunked', { + method: 'POST', + headers: { ...json, ...auth() }, + body: JSON.stringify({ filename: 'parallel.bin', mimeType: 'application/octet-stream', totalSize }), + }); + expect(res.status, await res.clone().text()).toBe(200); + return ((await res.json()) as any).data as { uploadId: string; resumeToken: string; fileId: string; totalChunks: number }; + }; + + const putChunk = async (uploadId: string, resumeToken: string, chunkIndex: number, bytes: Uint8Array) => { + const res = await stack.api(`/storage/upload/chunked/${uploadId}/chunk/${chunkIndex}`, { + method: 'PUT', + headers: { ...auth(), 'Content-Type': 'application/octet-stream', 'x-resume-token': resumeToken }, + body: bytes, + }); + expect(res.status, await res.clone().text()).toBe(200); + return ((await res.json()) as any).data.eTag as string; + }; + + const progress = async (uploadId: string) => { + const res = await stack.api(`/storage/upload/chunked/${uploadId}/progress`, { headers: auth() }); + expect(res.status, await res.clone().text()).toBe(200); + return ((await res.json()) as any).data as Record; + }; + + const fill = (byte: number, n: number) => new Uint8Array(n).fill(byte); + + beforeAll(async () => { + const rootDir = mkdtempSync(join(tmpdir(), 'chunked-parallel-dogfood-')); + stack = await bootStack(attachmentsFixtureStack as never, { + security: attachmentsFixtureSecurity(), + // `bindToSettings:false` keeps the constructor rootDir; the size limit is + // not what this file is about. + extraPlugins: [new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false })], + }); + token = await stack.signIn(); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('two chunks sent at once are both recorded, and the upload completes with the whole file', async () => { + const chunk0 = fill(0x61, MIN_CHUNK); + const chunk1 = fill(0x62, 10); + const { uploadId, resumeToken, fileId } = await init(MIN_CHUNK + 10); + + const [eTag0, eTag1] = await Promise.all([ + putChunk(uploadId, resumeToken, 0, chunk0), + putChunk(uploadId, resumeToken, 1, chunk1), + ]); + + expect(await progress(uploadId)).toMatchObject({ uploadedChunks: 2, uploadedSize: MIN_CHUNK + 10 }); + const res = await stack.api(`/storage/upload/chunked/${uploadId}/complete`, { + method: 'POST', + headers: { ...json, ...auth() }, + body: JSON.stringify({ + uploadId, + parts: [ + { chunkIndex: 0, eTag: eTag0 }, + { chunkIndex: 1, eTag: eTag1 }, + ], + }), + }); + expect(res.status, await res.clone().text()).toBe(200); + expect(((await res.json()) as any).data).toMatchObject({ fileId, size: MIN_CHUNK + 10 }); + }); + + it('eight chunks sent at once are all recorded', async () => { + const { uploadId, resumeToken, totalChunks } = await init(8 * MIN_CHUNK); + expect(totalChunks).toBe(8); + // Small, distinct sizes: the chunk door records what it is sent, and a lost + // part shows in both counts. + const chunks = Array.from({ length: 8 }, (_, i) => fill(0x61 + i, 10 + i)); + + await Promise.all(chunks.map((bytes, i) => putChunk(uploadId, resumeToken, i, bytes))); + + const total = chunks.reduce((sum, c) => sum + c.length, 0); + expect(await progress(uploadId)).toMatchObject({ uploadedChunks: 8, uploadedSize: total }); + }); +}); From 8e432893f91d8609d39e7bf71718e5cc3f986f51 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:11:00 +0000 Subject: [PATCH 5/6] chore(changeset): service-storage patch for the chunk door's compare-and-set progress write Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../22332-chunk-part-record-compare-and-set.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/22332-chunk-part-record-compare-and-set.md diff --git a/.changeset/22332-chunk-part-record-compare-and-set.md b/.changeset/22332-chunk-part-record-compare-and-set.md new file mode 100644 index 00000000000..644fabdfa5c --- /dev/null +++ b/.changeset/22332-chunk-part-record-compare-and-set.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-storage': patch +--- + +fix(service-storage): chunks sent in parallel to one chunked upload each record their part + +Clause-②: no + +`PUT /storage/upload/chunked/:uploadId/chunk/:chunkIndex` merges its chunk into the upload session's record of the chunks it holds (`parts`, `uploaded_chunks`, `uploaded_size` on `sys_upload_session`). It read that record, merged in memory and wrote the whole record back, so two chunk PUTs to one upload at the same time both answered `200` while the record kept only one of them: `GET …/progress` undercounted, and the completion was refused `409 RESOURCE_CONFLICT` naming the chunk the record had lost until the client sent it again. + +The record is now written with a compare-and-set: the write lands only while the row still holds the progress the chunk door read, and when another chunk's write landed first the door reads the record again and merges again. On a wired data engine this is the engine's own conditional update, evaluated in the same statement that writes, so it holds across server processes. Every chunk sent in parallel is recorded, and a parallel upload completes on its first completion. A sequential upload is unchanged. + +A chunk whose record write loses to another write on 16 attempts in a row is refused `409 RESOURCE_CONFLICT`, with `error.details` `{ chunkIndex, attempts }`: its bytes are stored, but the upload does not hold it. Send that chunk again. From 006344eb50aad472b817b8fdc56f72392c3569db Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:26:22 +0000 Subject: [PATCH 6/6] docs(permissions): the tenant-audit census counts the chunk door's conditional progress write Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 46 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 20 ++++---- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index b0925093257..51cc40e008e 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 50 of them — just over a fifth +typed `any` have no type to read, and there are 51 of them — just over a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **52 of the 236 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 237 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -150,8 +150,8 @@ now **0**: nothing on this surface threads a context that provably lacks the fla **"No tenant context" counted sites it had not read.** An options argument the walker could not parse was folded into the same bucket as one it had read and -found empty. That published **60 sites "carrying no tenant context at all"** -when 8 said so and 52 were simply unread — an over-claim in the *alarming* +found empty. That published **61 sites "carrying no tenant context at all"** +when 8 said so and 53 were simply unread — an over-claim in the *alarming* direction, on the very figure this page tells other cards to cite. `carries` is now three-valued, and an unreadable argument can never contribute to the provable count. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **236** | -| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 236** decidable, **80** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 125 decidably elevated, 0 decidably not, 103 undecidable | +| 175 write call sites | quoted in the merged changeset | **237** | +| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **157 of 237** decidable, **80** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 125 decidably elevated, 0 decidably not, 104 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,21 +200,21 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 50 sites reached +one, and both are counted in the generated tables below: the 51 sites reached through an erased (`any`) receiver, and the 50 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -125 of 236 (53%) as decidably elevated, with 103 more whose elevation is a +125 of 237 (53%) as decidably elevated, with 104 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 236`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 237`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites -without tenant context" — **31 further sites** have an options argument this +without tenant context" — **32 further sites** have an options argument this cannot read, and they are neither in nor out. {/* BEGIN GENERATED: tenant-audit-census (scripts/tenant-audit-census.mjs) — DO NOT EDIT */} @@ -223,28 +223,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **236** | -| …whose object name is statically decidable | 156 | +| write call sites on the application surface | **237** | +| …whose object name is statically decidable | 157 | | …whose object name is chosen at run time | 80 | -| …against an object with tenancy ENABLED | 155 | +| …against an object with tenancy ENABLED | 156 | | …against an object that declares tenancy off | 1 | | threading a tenant context | 176 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | -| options argument UNREADABLE — may or may not carry one | 52 | -| …of those, against a decidably tenancy-enabled object | 31 | +| options argument UNREADABLE — may or may not carry one | 53 | +| …of those, against a decidably tenancy-enabled object | 32 | | threading a decidably ELEVATED (`isSystem`) context | 125 | | threading a context that is decidably NOT elevated | 0 | -| threading a context whose elevation is a run-time fact | 103 | +| threading a context whose elevation is a run-time fact | 104 | | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 186 | -| receiver erased, placed by the object NAME | 30 | +| receiver erased, placed by the object NAME | 31 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 106 | +| object name spelled inline | 107 | | object name spelled through a `const` | 50 | | object name is an `object: string` parameter | 19 | | object name is some other run-time expression | 61 | @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-08 at `4b40ca31f`. +Measured on 2026-10-08 at `8e432893f`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 618 | +| tracked non-test sources scanned | 621 | | engine-shaped types recognised | 70 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 161 | +| same-named calls subtracted as non-engine | 162 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 129ff421142..975bbe16d47 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 236 | -| Object name statically decidable | 156 | +| Write call sites | 237 | +| Object name statically decidable | 157 | | Object name chosen at run time | 80 | -| Against a tenancy-enabled object | 155 | +| Against a tenancy-enabled object | 156 | | Against an object declaring tenancy off | 1 | | Threading a tenant context | 176 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | -| Options argument unreadable | 52 | -| …and decidably tenancy-enabled | 31 | +| Options argument unreadable | 53 | +| …and decidably tenancy-enabled | 32 | | Threading a decidably elevated context | 125 | | Threading a decidably non-elevated context | 0 | -| Threading a context of undecidable elevation | 103 | +| Threading a context of undecidable elevation | 104 | ## Subtractions the census could NOT defend — enforced @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-08 at `4b40ca31f`. +Measured on 2026-10-08 at `8e432893f`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 618 | +| tracked non-test sources scanned | 621 | | engine-shaped types recognised | 70 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 161 | +| same-named calls subtracted as non-engine | 162 | ## Every site @@ -255,4 +255,4 @@ Measured on 2026-10-08 at `4b40ca31f`. | `packages/services/service-storage/src/metadata-store.ts` | `update` | `sys_file` | enabled | options unreadable | 1 | | `packages/services/service-storage/src/metadata-store.ts` | `delete` | `sys_upload_session` | enabled | options unreadable | 1 | | `packages/services/service-storage/src/metadata-store.ts` | `insert` | `sys_upload_session` | enabled | options unreadable | 1 | -| `packages/services/service-storage/src/metadata-store.ts` | `update` | `sys_upload_session` | enabled | options unreadable | 1 | +| `packages/services/service-storage/src/metadata-store.ts` | `update` | `sys_upload_session` | enabled | options unreadable | 2 |