|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#14244] `AutomationContext.recordLoadDenied` is exactly the producer's |
| 5 | + * shape — `{ recordLoadDenied?: true }`, the return type of |
| 6 | + * `actionRecordLoadSignal` (`@objectstack/runtime`, `action-execution.ts`) — |
| 7 | + * the flow face of #14143's handler-face signal, MIRRORED rather than |
| 8 | + * respelled (triage ruling on #14244, 2026-09-02: the key must mirror the |
| 9 | + * producer's spelling rather than invent a second one). |
| 10 | + * |
| 11 | + * Four things are pinned, because each drifts on its own: |
| 12 | + * |
| 13 | + * 1. **The key's type, at the type level.** Exactly `true | undefined` — a |
| 14 | + * widening to `boolean` would let a producer emit `false`, which the |
| 15 | + * handler face documents it never does (`ctx.recordLoadDenied === true`, |
| 16 | + * absent otherwise). A TypeScript interface member has no Zod schema, so |
| 17 | + * the only assertion is a compile-time identity (`Eq`, the |
| 18 | + * `automation-result-status.pin.test.ts` form), read by |
| 19 | + * `check:test-typecheck` under `tsconfig.test.json`. |
| 20 | + * 2. **Additive.** A context literal WITHOUT the key still type-checks, so no |
| 21 | + * existing caller of `IAutomationService.execute` moves. |
| 22 | + * 3. **`false` is refused at compile time** (`@ts-expect-error`). A phantom |
| 23 | + * unless this file is compiled — and it is: `tsconfig.test.json` includes |
| 24 | + * `src/**`, and its ledger (`test-typecheck-debt.json`) lists this file |
| 25 | + * nowhere, so the file must compile with exactly zero errors. |
| 26 | + * 4. **The JSDoc says who sets it and that the flow face does not yet |
| 27 | + * populate it.** Prose is unassertable except by reading it; the contract |
| 28 | + * source is read and the doc block above the key is required to name the |
| 29 | + * producer and the not-yet-populated state, so the day the runtime half |
| 30 | + * lands, this test tells its author which sentence to retire. |
| 31 | + * |
| 32 | + * ⛔ Not pinned, deliberately: that any flow run actually RECEIVES the key. |
| 33 | + * That is the runtime half (`dispatchFlowAction` / the REST `/actions` door |
| 34 | + * passing the producer's signal into the run's context), a separate card. |
| 35 | + */ |
| 36 | + |
| 37 | +import { readFileSync } from 'node:fs'; |
| 38 | +import { fileURLToPath } from 'node:url'; |
| 39 | + |
| 40 | +import { describe, it, expect } from 'vitest'; |
| 41 | + |
| 42 | +import type { AutomationContext, IAutomationService } from './automation-service'; |
| 43 | + |
| 44 | +/** Type-level identity: true iff A and B are the same type. */ |
| 45 | +type Eq< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; |
| 46 | +/** Compile error when the argument is not `true`. */ |
| 47 | +type Assert< T extends true > = T; |
| 48 | + |
| 49 | +/** |
| 50 | + * Exported deliberately — an unread alias inside a test body is TS6196, and a |
| 51 | + * pin no program compiles is no pin at all. |
| 52 | + */ |
| 53 | +export type RecordLoadDeniedIsExactlyTrueOrUndefined = Assert< Eq< AutomationContext['recordLoadDenied'], true | undefined > >; |
| 54 | +/** The producer's return shape, spelled here as the runtime spells it. */ |
| 55 | +type ProducerSignal = { recordLoadDenied?: true }; |
| 56 | +/** The contract key is assignable FROM the producer's signal — the mirror holds in the direction that matters. */ |
| 57 | +export type ProducerSignalSpreadsIntoContext = Assert< Eq< ProducerSignal['recordLoadDenied'], AutomationContext['recordLoadDenied'] > >; |
| 58 | + |
| 59 | +/** Positive control (additive): the key is optional, so a pre-#14244 context still type-checks. */ |
| 60 | +export const contextWithoutTheKey: AutomationContext = { record: { id: 'rec-1', name: 'Alice' }, object: 'crm_deal', userId: 'u1' }; |
| 61 | +/** The one value the key may carry. */ |
| 62 | +export const contextWithTheKey: AutomationContext = { record: { id: 'rec-1' }, object: 'crm_deal', userId: 'u1', recordLoadDenied: true }; |
| 63 | +// @ts-expect-error — `false` is not a member: the key is ABSENT, never `false` (handler-face convention, mirrored). |
| 64 | +export const contextWithFalse: AutomationContext = { record: { id: 'rec-1' }, object: 'crm_deal', recordLoadDenied: false }; |
| 65 | + |
| 66 | +describe('[#14244] AutomationContext.recordLoadDenied mirrors the producer signal', () => { |
| 67 | + it('reads the key back as exactly `true`, and its absence as `undefined` (anti-vacuity)', () => { |
| 68 | + expect(contextWithTheKey.recordLoadDenied).toBe(true); |
| 69 | + expect(contextWithoutTheKey.recordLoadDenied).toBeUndefined(); |
| 70 | + expect('recordLoadDenied' in contextWithoutTheKey).toBe(false); |
| 71 | + }); |
| 72 | + |
| 73 | + it('a service implementation can guard on it WITHOUT a cast, the way a runAs:system flow would', async () => { |
| 74 | + const seen: Array<true | undefined> = []; |
| 75 | + const service: IAutomationService = { |
| 76 | + execute: async (_flowName, context?) => { |
| 77 | + seen.push(context?.recordLoadDenied); |
| 78 | + // The documented predicate, verbatim: `=== true`, never a truthiness of `false`. |
| 79 | + if (context?.recordLoadDenied === true) return { success: false, error: 'RECORD_NOT_FOUND' }; |
| 80 | + return { success: true }; |
| 81 | + }, |
| 82 | + listFlows: async () => [], |
| 83 | + }; |
| 84 | + expect((await service.execute('guarded', contextWithTheKey)).success).toBe(false); |
| 85 | + expect((await service.execute('guarded', contextWithoutTheKey)).success).toBe(true); |
| 86 | + expect(seen).toEqual([true, undefined]); |
| 87 | + }); |
| 88 | + |
| 89 | + it('the contract JSDoc names the producer, both doors, and the not-yet-populated flow face', () => { |
| 90 | + const source = readFileSync(fileURLToPath(new URL('./automation-service.ts', import.meta.url)), 'utf8'); |
| 91 | + const declaration = 'recordLoadDenied?: true;'; |
| 92 | + const at = source.indexOf(declaration); |
| 93 | + expect(at).toBeGreaterThan(-1); |
| 94 | + // Exactly one declaration of the key on the contract — a second spelling |
| 95 | + // anywhere in this file is the drift the ruling forbids. |
| 96 | + expect(source.indexOf('recordLoadDenied', at + declaration.length)).toBe(-1); |
| 97 | + // The doc block immediately above the declaration — from its last `/**`. |
| 98 | + const docStart = source.lastIndexOf('/**', at); |
| 99 | + const doc = source.slice(docStart, at); |
| 100 | + expect(doc).toContain('loadActionSubjectRecord'); |
| 101 | + expect(doc).toContain('actionRecordLoadSignal'); |
| 102 | + expect(doc).toContain('{ recordLoadDenied?: true }'); |
| 103 | + expect(doc).toMatch(/both doors/i); |
| 104 | + expect(doc).toMatch(/run_action/); |
| 105 | + expect(doc).toMatch(/runAs: 'system'/); |
| 106 | + // The honesty clause: declared, not yet populated on the flow face. |
| 107 | + expect(doc).toMatch(/NOT YET POPULATED/); |
| 108 | + expect(doc).toContain('dispatchFlowAction'); |
| 109 | + // Absence semantics, in the handler face's own words. |
| 110 | + expect(doc).toMatch(/never\s+\*?\s*`false`/); |
| 111 | + }); |
| 112 | +}); |
0 commit comments