diff --git a/.changeset/23531-hono-conflict-current-record.md b/.changeset/23531-hono-conflict-current-record.md new file mode 100644 index 00000000000..d24552f59a1 --- /dev/null +++ b/.changeset/23531-hono-conflict-current-record.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-hono-server': patch +'@objectstack/hono': patch +--- + +fix(plugin-hono-server): a `409 CONCURRENT_UPDATE` answered by either Hono door carries `currentRecord` as well, the caller's own read of the row, as the standalone REST server's does (#23531) + +The two Hono doors are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`. Their answer to a stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` carried the server's `currentVersion` in `error.details`, and held `currentRecord` back while the protocol read that row with a system context. On a version mismatch the protocol now re-reads the row under the caller's own context (#23586), so the record holds only what the caller may read, and these doors forward it. + +- FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…"}}}` +- TO `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…","currentRecord":{…}}}}` + +`currentRecord` is the row as the caller reads it. A field the caller's field-level rules hide is absent from it, and a row the caller's row-level rules hide answers `404 RECORD_NOT_FOUND` with neither field, as the REST server answers. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it, and `@objectstack/client` reads it as `err.details.currentRecord` on these hosts. Status, `code`, `message` and `currentVersion` are unchanged, and no other answer changes. + +This supersedes the earlier note that `currentRecord` stays held on these two doors. Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`. + +Clause-②: no diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx index bf9b704d7e1..1dfed5290f4 100644 --- a/content/docs/api/index.mdx +++ b/content/docs/api/index.mdx @@ -218,7 +218,7 @@ Validation failures additionally include a `fields` array (one entry per invalid } ``` -On a host built with `@objectstack/hono`'s `createHonoApp`, a declared refusal thrown out of the dispatcher (the `/data` routes' refusals among them, where its catch-all serves `/data`; a host that registers the kernel REST server's routes ahead of the catch-all, the hosted composition, answers those flat, as above) is answered in this envelope **without** `httpStatus`, and so is one escaping a route mounted on `HonoHttpServer`. The fields the kernel REST server's classification adds beside `code` ride in `details` there, for example `DELETE_RESTRICTED`'s `dependentObject`, except `currentRecord` and an `object` taken from the request's route — see [Wire Format](/docs/api/wire-format#hono-hosts-nest-the-extras). +On a host built with `@objectstack/hono`'s `createHonoApp`, a declared refusal thrown out of the dispatcher (the `/data` routes' refusals among them, where its catch-all serves `/data`; a host that registers the kernel REST server's routes ahead of the catch-all, the hosted composition, answers those flat, as above) is answered in this envelope **without** `httpStatus`, and so is one escaping a route mounted on `HonoHttpServer`. The fields the kernel REST server's classification adds beside `code` ride in `details` there, for example `DELETE_RESTRICTED`'s `dependentObject`, except an `object` taken from the request's route — see [Wire Format](/docs/api/wire-format#hono-hosts-nest-the-extras). Branch on `error.code`, never on `error.httpStatus` — the status answers "what class of failure" and the code answers "which failure". A branch that has no diff --git a/content/docs/api/wire-format.mdx b/content/docs/api/wire-format.mdx index a11fddd7029..6a186ed8770 100644 --- a/content/docs/api/wire-format.mdx +++ b/content/docs/api/wire-format.mdx @@ -487,9 +487,11 @@ release group); any code reading them directly should move to `error.code`. A host built with `@objectstack/hono`'s `createHonoApp`, and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`, answer a declared refusal that escapes the handler in the nested envelope above, without `httpStatus`. The extras the -REST door's classification adds (`fields`, `currentVersion`, `dependentObject`, …) ride in -`error.details`; `currentRecord`, and an `object` the flat body takes from the request's -route, are not sent. The concurrent update above, on a `createHonoApp` host whose catch-all serves `/data`: +REST door's classification adds (`fields`, `currentVersion`, `currentRecord`, `dependentObject`, …) +ride in `error.details`; an `object` the flat body takes from the request's route is not +sent. `currentRecord` is the row as the caller reads it, on either door: a field the caller's +field-level rules hide is absent from it. The concurrent update above, on a `createHonoApp` +host whose catch-all serves `/data`: ```json { @@ -497,7 +499,14 @@ route, are not sent. The concurrent update above, on a `createHonoApp` host whos "error": { "code": "CONCURRENT_UPDATE", "message": "Record task/tsk_01HQ4A7B9D3F5G8J2K4L was modified by another user (current version 2025-01-20T14:15:00.000Z, expected 2025-01-20T14:10:00.000Z)", - "details": { "currentVersion": "2025-01-20T14:15:00.000Z" } + "details": { + "currentVersion": "2025-01-20T14:15:00.000Z", + "currentRecord": { + "id": "tsk_01HQ4A7B9D3F5G8J2K4L", + "title": "Implement login page", + "updated_at": "2025-01-20T14:15:00.000Z" + } + } } } ``` diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx index 4e9e6e32909..fbe03d3cce7 100644 --- a/content/docs/protocol/kernel/error-handling.mdx +++ b/content/docs/protocol/kernel/error-handling.mdx @@ -247,14 +247,13 @@ serves `/data`: | Code | In `error.details` on a Hono door | |---|---| | `DELETE_RESTRICTED` | `developerMessage`, `dependentObject`, and `dependentCount` when the flat door would send it | -| `CONCURRENT_UPDATE` | `currentVersion` | +| `CONCURRENT_UPDATE` | `currentVersion`, `currentRecord` (the row as the caller reads it) | | `INVALID_FIELD` | `field`, `object` | | `OBJECT_NOT_FOUND` | `object` | | `VALIDATION_FAILED` | `fields` | | `FEEDS_DISABLED`, `FILES_DISABLED`, `ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE` | `object` | -⛔ What a Hono door never sends: `httpStatus`; `CONCURRENT_UPDATE`'s `currentRecord`, -which the flat door sends; and an `object` the flat door takes from the request's route +⛔ What a Hono door never sends: `httpStatus`, and an `object` the flat door takes from the request's route (on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `VALIDATION_FAILED`). The `message` is the thrown one and can differ from the flat door's `error`: an unregistered object reads `Object 'ghost' not found` here and `Object 'ghost' is not registered` there. diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index 7fd1f090b05..4b7ca0f6247 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -296,12 +296,13 @@ function toLoggableError(thrown: unknown): Error { * ## A structured refusal's context (#23531, #23587) * * A refusal `RestServer`'s classification answers with structured fields - * (`409 CONCURRENT_UPDATE`'s `currentVersion`, `409 DELETE_RESTRICTED`'s - * `developerMessage` / `dependentObject` / `dependentCount`, …) also carries - * them here, in `details`, read off that classification by - * {@link declaredArmContext}. Before this, both Hono doors answered those - * refusals with `code` and `message` only. `currentRecord` is held back for - * now (#23586); that helper's docblock says why. + * (`409 CONCURRENT_UPDATE`'s `currentVersion` and `currentRecord`, `409 + * DELETE_RESTRICTED`'s `developerMessage` / `dependentObject` / + * `dependentCount`, …) also carries them here, in `details`, read off that + * classification by {@link declaredArmContext}. Before this, both Hono doors + * answered those refusals with `code` and `message` only. `currentRecord` is + * forwarded because the producer reads it under the caller's own context + * (#23586, PR #23628); that helper's docblock says why that matters. */ export function declaredEnvelopeForThrow(thrown: unknown): { status: number; @@ -378,9 +379,8 @@ export function declaredEnvelopeForThrow(thrown: unknown): { * under the same rule (each field only when the producer stated it), and an * arm that grows a field grows it at every door. ⛔ Never list the fields or * the codes here. The arm's own sentence and code are dropped because this - * envelope already carries both, as `message` and `code`. `currentRecord` is - * the ONE field named here, and it is named only to hold it back (the ⚠️ - * paragraph below says why). + * envelope already carries both, as `message` and `code`. No field is held + * back by name (the ⚠️ paragraph below says what that rests on). * * ## The arm must agree with the envelope * @@ -412,18 +412,19 @@ export function declaredEnvelopeForThrow(thrown: unknown): { * `object` (`INVALID_FIELD`, `OBJECT_NOT_FOUND`, the 403 access arms) carry it * here as they do there. * - * ⚠️ `currentRecord` is HELD here: these doors do not forward it, though the - * REST door does. The protocol reads that row with a system context - * (`probeRecord`), and the OCC check throws it before the caller's write - * policy runs, so it is not narrowed to what the caller may read. It may carry - * a row, or fields, that row- or field-level security hides from the caller. - * Forwarding it would widen that possible disclosure to two more doors. The - * record waits for the producer's answer (#23586), and the exclusion is lifted - * when that lands. Every other field an arm carries today is a version token, + * ⚠️ Row data rides here only because its producer reads it as the caller. + * `CONCURRENT_UPDATE`'s `currentRecord` is a row: on a version mismatch the + * protocol re-reads it under the caller's own `context`, so every row- and + * field-level rule of the read door applies (#23586, PR #23628). A row the + * caller cannot read answers `404 RECORD_NOT_FOUND` with neither field, and a + * readable row's `currentRecord` is that field-masked read, which these doors + * forward as the REST door does. It was held here until that landed, while + * the record was the row the existence probe (`probeRecord`) reads with a + * system context. Every other field an arm carries today is a version token, * an API name, the caller's own input, or a count the producer has already * narrowed to what the caller may read (`dependentCountIsDisclosable` in - * `@objectstack/objectql`); none is row data read with a system context. An - * arm that grows such a field must be held here by name, as this one is. + * `@objectstack/objectql`). An arm that grows a field read with a system + * context must be held here by name until its producer reads it as the caller. */ function declaredArmContext( thrown: unknown, @@ -434,10 +435,7 @@ function declaredArmContext( return undefined; } const context = Object.fromEntries( - Object.entries(arm.body).filter(([key]) => key !== 'error' - && key !== 'code' - // Held: a system-context row the caller may not be allowed to read (#23586). - && key !== 'currentRecord'), + Object.entries(arm.body).filter(([key]) => key !== 'error' && key !== 'code'), ); return Object.keys(context).length > 0 ? context : undefined; } diff --git a/packages/plugins/plugin-hono-server/src/handler-throw-declared-envelope.test.ts b/packages/plugins/plugin-hono-server/src/handler-throw-declared-envelope.test.ts index e368ba93f3c..b2e45fc2305 100644 --- a/packages/plugins/plugin-hono-server/src/handler-throw-declared-envelope.test.ts +++ b/packages/plugins/plugin-hono-server/src/handler-throw-declared-envelope.test.ts @@ -216,27 +216,28 @@ describe('an escaped throw carrying a declared ADR-0112 envelope is rendered as /** * [#23531] A stale `If-Match` on a direct-mount route: the `409 - * CONCURRENT_UPDATE` carries the server's `currentVersion`, which `RestServer`'s - * `409` carries and objectui's conflict bar reads. + * CONCURRENT_UPDATE` carries the server's `currentVersion` and `currentRecord`, + * which `RestServer`'s `409` carries and objectui's conflict bar reads. * - * `currentRecord` is asserted ABSENT. `RestServer` forwards it, but the - * protocol reads that row with a system context before the caller's write - * policy runs, so it is held at these doors until the producer answers #23586. - * The producer below states it, so the absence is the door's doing. + * `currentRecord` is forwarded as the producer threw it, field for field: the + * row below stands for the caller's own read, which is what `metadata-protocol` + * puts there since #23586 (PR #23628). `error.details` is asserted EXACTLY, so + * a field the door drops or invents turns the row red. * * The producer below is shaped exactly like `metadata-protocol`'s * `ConcurrentUpdateError` (`name`, `code`, `status`, and the two own * properties), because this package does not depend on that one. The routes * judge the `If-Match` header against a stored version, as `updateData` / * `deleteData` do. The composed-host twin, over the REAL protocol, is - * `hono-declared-envelope.conformance.test.ts` in `packages/qa/http-conformance`. + * `hono-declared-envelope.conformance.test.ts` in `packages/qa/http-conformance`; + * it also shows a field the caller's read masks staying off `currentRecord`. * - * On the wrapped envelope the field rides `error.details`: that is the + * On the wrapped envelope the fields ride `error.details`: that is the * envelope's declared context slot (`ApiErrorSchema.details`), and it is where * `@objectstack/client` reads a wrapped body's context from * (`errorBody.error.details`). */ -describe('[#23531] a stale If-Match on a direct-mount route: the 409 carries currentVersion, and holds currentRecord (#23586)', () => { +describe('[#23531] a stale If-Match on a direct-mount route: the 409 carries currentVersion and currentRecord', () => { class ConcurrentUpdateError extends Error { readonly code = 'CONCURRENT_UPDATE'; readonly status = 409; @@ -274,7 +275,7 @@ describe('[#23531] a stale If-Match on a direct-mount route: the 409 carries cur return s; } - it.each(['PATCH', 'DELETE'])('%s: 409 CONCURRENT_UPDATE with currentVersion in `error.details`, and no currentRecord', async (method) => { + it.each(['PATCH', 'DELETE'])('%s: 409 CONCURRENT_UPDATE with currentVersion and currentRecord in `error.details`, as the producer stated them', async (method) => { const res = await call(occServer(), '/api/v1/data/task/task_1', { method, headers: { 'If-Match': `"${STALE_VERSION}"`, ...(method === 'PATCH' ? { 'content-type': 'application/json' } : {}) }, @@ -285,10 +286,8 @@ describe('[#23531] a stale If-Match on a direct-mount route: the 409 carries cur expect(res.status, JSON.stringify(body)).toBe(409); expect(body.success).toBe(false); expect(body.error.code).toBe('CONCURRENT_UPDATE'); - expect(body.error.details).toEqual({ currentVersion: STORED_VERSION }); - // Held until #23586: the producer stated it, and the door does not forward it. - expect(body.error.details).not.toHaveProperty('currentRecord'); - expect(JSON.stringify(body)).not.toContain('original'); + // Exactly the two fields the producer stated: nothing dropped, nothing added. + expect(body.error.details).toEqual({ currentVersion: STORED_VERSION, currentRecord: STORED }); }); it('control: the version the route stores is accepted, so the 409 rows are the guard and not the rig', async () => { diff --git a/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts b/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts index 5cc14f9edc4..971d2181fca 100644 --- a/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts +++ b/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts @@ -117,6 +117,21 @@ const CONTACT = { }, }; +/** + * [#23531] An object whose `secret` a caller's read masks (see the + * `[#23531]` block). The stored row keeps it; only a read issued with a + * caller's context loses it. + */ +const LEDGER = { + name: 'ledger', + label: 'Ledger', + fields: { + name: { name: 'name', type: 'text' as const, label: 'Name' }, + secret: { name: 'secret', type: 'text' as const, label: 'Secret' }, + }, +}; +const SECRET_TEXT = 'ledger-secret-the-caller-may-not-read'; + const liveKernels: LiteKernel[] = []; afterEach(async () => { vi.restoreAllMocks(); @@ -153,7 +168,7 @@ async function boot(auth?: Record) { await kernel.bootstrap(); const ql = kernel.getService('objectql'); - for (const object of [TASK, ACCOUNT, CONTACT]) { + for (const object of [TASK, ACCOUNT, CONTACT, LEDGER]) { ql.registerObject(object as any); await ql.syncObjectSchema(object.name); } @@ -265,34 +280,69 @@ describe('[#23365] a real createHonoApp host: the dispatch() catch-all renders a /** * [#23531] The stale-`If-Match` `409` above carries the server's - * `currentVersion`, as `RestServer`'s does. The wire contract and - * `ConcurrentUpdateError`'s docblock name it, and objectui's conflict bar reads - * it. Before this card the body was `code` and `message` only. + * `currentVersion` and `currentRecord`, as `RestServer`'s does. The wire + * contract and `ConcurrentUpdateError`'s docblock name both, and objectui's + * conflict bar reads both. Before this card the body was `code` and `message` + * only. * - * The field rides `error.details`, the wrapped envelope's declared context + * The fields ride `error.details`, the wrapped envelope's declared context * slot, which is where `@objectstack/client` reads a wrapped body's context * from. `currentVersion` is proved by USE, not by spelling: echoed back as the * next `If-Match`, it is accepted ("Overwrite anyway" in the conflict bar), so - * it is the record's real version token. + * it is the record's real version token. `error.details` is compared with the + * throw the real protocol raised, read off the protocol call, so the door is + * shown to relay the producer's two fields rather than to build its own. + * + * ## The record is the caller's read * - * `currentRecord` is asserted ABSENT. The real protocol below states it (the - * system-context row its `probeRecord` read, thrown before the caller's write - * policy runs), and `RestServer` forwards it. These doors hold it until the - * producer answers #23586, so the absence is the door's doing, not the - * producer's. + * On a version mismatch the protocol re-reads the row under the caller's own + * context (#23586, PR #23628), so a field the caller's read masks is absent + * from `currentRecord` on this door, while the store still holds it. The mask + * here is an engine middleware that rewrites a read issued with a caller's + * context after `next()`: the seam `@objectstack/plugin-security`'s + * field-level rules rewrite a read at. The security plugin itself is not + * booted, because this package does not depend on it. Its real field- and + * row-level rules are pinned at the producer, on `RestServer` and on the + * dispatcher, by `packages/runtime`'s `data-conflict-caller-visibility.test.ts`. */ -describe('[#23531] a real createHonoApp host: the 409 CONCURRENT_UPDATE carries currentVersion, and holds currentRecord (#23586)', () => { - it('PATCH with a stale If-Match: currentVersion in `error.details` and accepted when echoed; no currentRecord', async () => { +describe('[#23531] a real createHonoApp host: the 409 CONCURRENT_UPDATE carries currentVersion and currentRecord', () => { + type ProtocolWrite = 'updateData' | 'deleteData'; + const protocolWrite = (method: string): ProtocolWrite => (method === 'PATCH' ? 'updateData' : 'deleteData'); + + /** Each throw the real protocol raises from `write`, read off the call and rethrown. */ + const captureThrows = (protocol: any, write: ProtocolWrite): any[] => { + const produced: any[] = []; + const real = protocol[write].bind(protocol); + vi.spyOn(protocol, write).mockImplementation(async (...args: any[]) => { + try { + return await real(...args); + } catch (thrown) { + produced.push(thrown); + throw thrown; + } + }); + return produced; + }; + /** A thrown value as the wire carries it. */ + const asWire = (value: unknown) => JSON.parse(JSON.stringify(value)); + + it('PATCH with a stale If-Match: currentVersion and currentRecord in `error.details` as the protocol threw them, and the version accepted when echoed', async () => { const host = await boot(); const id = await host.seed('original'); + const produced = captureThrows(host.protocol, 'updateData'); const answer = await host.send('PATCH', `/data/task/${id}`, { headers: { 'If-Match': STALE_VERSION }, body: { name: 'lost update' } }); expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 409, success: false, code: 'CONCURRENT_UPDATE' }); + // The real producer stated both; without this the equality below + // could hold between two absences. + expect(produced).toHaveLength(1); + expect(produced[0].currentRecord, JSON.stringify(produced[0])).toMatchObject({ id, name: 'original' }); const details = answer.body.error.details; - expect(typeof details?.currentVersion, JSON.stringify(answer.body)).toBe('string'); - // Held until #23586: the protocol stated it, and the door does not forward it. - expect(details, JSON.stringify(answer.body)).not.toHaveProperty('currentRecord'); - expect(JSON.stringify(answer.body)).not.toContain('original'); + expect(details, JSON.stringify(answer.body)).toEqual({ + currentVersion: produced[0].currentVersion, + currentRecord: asWire(produced[0].currentRecord), + }); + expect(typeof details.currentVersion).toBe('string'); expect(await host.stored(id)).toBe('original'); const overwrite = await host.send('PATCH', `/data/task/${id}`, { headers: { 'If-Match': `"${details.currentVersion}"` }, body: { name: 'overwritten' } }); @@ -300,23 +350,62 @@ describe('[#23531] a real createHonoApp host: the 409 CONCURRENT_UPDATE carries expect(await host.stored(id)).toBe('overwritten'); }, 60_000); - it('DELETE with a stale If-Match: currentVersion in `error.details` and accepted when echoed; no currentRecord', async () => { + it('DELETE with a stale If-Match: currentVersion and currentRecord in `error.details` as the protocol threw them, and the version accepted when echoed', async () => { const host = await boot(); const id = await host.seed('kept'); + const produced = captureThrows(host.protocol, 'deleteData'); const answer = await host.send('DELETE', `/data/task/${id}`, { headers: { 'If-Match': STALE_VERSION } }); expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 409, success: false, code: 'CONCURRENT_UPDATE' }); + expect(produced).toHaveLength(1); + expect(produced[0].currentRecord, JSON.stringify(produced[0])).toMatchObject({ id, name: 'kept' }); const details = answer.body.error.details; - expect(typeof details?.currentVersion, JSON.stringify(answer.body)).toBe('string'); - // Held until #23586: the protocol stated it, and the door does not forward it. - expect(details, JSON.stringify(answer.body)).not.toHaveProperty('currentRecord'); - expect(JSON.stringify(answer.body)).not.toContain('kept'); + expect(details, JSON.stringify(answer.body)).toEqual({ + currentVersion: produced[0].currentVersion, + currentRecord: asWire(produced[0].currentRecord), + }); + expect(typeof details.currentVersion).toBe('string'); expect(await host.stored(id)).toBe('kept'); const overwrite = await host.send('DELETE', `/data/task/${id}`, { headers: { 'If-Match': `"${details.currentVersion}"` } }); expect(overwrite.status, JSON.stringify(overwrite.body)).toBe(200); expect(await host.stored(id)).toBeUndefined(); }, 60_000); + + it.each(['PATCH', 'DELETE'])('%s with a stale If-Match on a row whose field the caller\'s read masks: currentRecord is the caller\'s read, without that field', async (method) => { + const host = await boot(); + host.ql.registerMiddleware(async (op: any, next: () => Promise) => { + await next(); + if (op.object !== 'ledger' || (op.operation !== 'find' && op.operation !== 'findOne')) return; + if (!op.context || op.context.isSystem === true) return; + const rows = Array.isArray(op.result) ? op.result : op.result ? [op.result] : []; + for (const row of rows) delete row.secret; + }); + const created: any = await host.ql.insert('ledger', { name: 'visible', secret: SECRET_TEXT }); + const id = String(created.id); + const produced = captureThrows(host.protocol, protocolWrite(method)); + + const answer = await host.send(method, `/data/ledger/${id}`, { + headers: { 'If-Match': STALE_VERSION }, + ...(method === 'PATCH' ? { body: { name: 'lost update' } } : {}), + }); + expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 409, success: false, code: 'CONCURRENT_UPDATE' }); + + const record = answer.body.error.details?.currentRecord; + expect(record, JSON.stringify(answer.body)).toMatchObject({ id, name: 'visible' }); + expect(record).not.toHaveProperty('secret'); + expect(JSON.stringify(answer.body)).not.toContain(SECRET_TEXT); + // It is the caller's own read of the row, and the record the protocol threw. + const callerRead = await host.ql.findOne('ledger', { where: { id }, context: { ...MEMBER } }); + expect(callerRead).not.toHaveProperty('secret'); + expect(record).toEqual(asWire(callerRead)); + expect(produced).toHaveLength(1); + expect(record).toEqual(asWire(produced[0].currentRecord)); + // The store still holds the field and the row, so the absence is the + // caller's mask, not the row's content. + const systemRead: any = await host.ql.findOne('ledger', { where: { id } }); + expect(systemRead).toMatchObject({ name: 'visible', secret: SECRET_TEXT }); + }, 60_000); }); /**