|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#15937] `ToolExecutionContext.confirmedBlueprintIdentity`, pinned where a |
| 5 | + * grep can find it. |
| 6 | + * |
| 7 | + * The field arrives in the protocol because a PUBLISHED handler already |
| 8 | + * authorizes on it: cloud's `apply_blueprint` gate makes a matching |
| 9 | + * blueprint-identity digest one clause of the decision to build a whole app |
| 10 | + * (cloud#1954 / cloud PR #2005), and until this card it read the value off the |
| 11 | + * execution context through a structural cast against a declaration that lived |
| 12 | + * only in that one consumer's augmented type. A consent field a handler |
| 13 | + * authorizes on belongs in the declared contract. |
| 14 | + * |
| 15 | + * ## What is pinned, and what is deliberately NOT |
| 16 | + * |
| 17 | + * Pinned: that the member lives on `ToolExecutionContext` itself (not on some |
| 18 | + * neighbouring options bag), that it reaches a handler through the same |
| 19 | + * `ChatWithToolsOptions.toolExecutionContext` thread every other field uses, |
| 20 | + * that it is OPTIONAL and reads back `undefined` when absent, that its read |
| 21 | + * type is `string | undefined` rather than `string` — so a handler cannot |
| 22 | + * compile a path that assumes a confirmation is always present — and that it |
| 23 | + * is typed `string` rather than `any`, which is what makes the two |
| 24 | + * `@ts-expect-error` legs below detect anything at all. |
| 25 | + * |
| 26 | + * NOT pinned: any authorization behaviour. The gate that consumes this field |
| 27 | + * lives in `objectstack-ai/cloud` and is not in this tree, so a behavioural |
| 28 | + * assertion here would be a stub asserting itself. This file pins the |
| 29 | + * DECLARATION half, which is the half this repository owns. |
| 30 | + * |
| 31 | + * ## Why every leg carries a control |
| 32 | + * |
| 33 | + * A `@ts-expect-error` is only a check while the line under it really is an |
| 34 | + * error: an unused directive is itself a `tsc` error (TS2578) under |
| 35 | + * `packages/spec`'s test-layer program, so each negative leg goes red — rather |
| 36 | + * than quietly green — the day the type stops refusing what it names. The |
| 37 | + * positive legs are the other half of the same pair: the near-miss spelling is |
| 38 | + * refused only because the correct spelling on the line above is accepted. |
| 39 | + */ |
| 40 | + |
| 41 | +import { describe, it, expect } from 'vitest'; |
| 42 | + |
| 43 | +import type { ChatWithToolsOptions, ToolExecutionContext } from './ai-service'; |
| 44 | + |
| 45 | +/** Identity helper, so a rejected shape errors on the argument's own line. */ |
| 46 | +const asCtx = (c: ToolExecutionContext) => c; |
| 47 | +const asOptions = (o: ChatWithToolsOptions) => o; |
| 48 | + |
| 49 | +/** A plausible digest — the shape cloud stamps, opaque to this contract. */ |
| 50 | +const DIGEST = 'sha256:3f6a1c0e9b2d4a7f8c5e1b0d9a2f4c6e8b0d3a5f7c9e1b3d5a7f9c1e3b5d7a9f'; |
| 51 | + |
| 52 | +describe('confirmedBlueprintIdentity contract (#15937)', () => { |
| 53 | + it('is declared on `ToolExecutionContext` and round-trips the digest', () => { |
| 54 | + const ctx = asCtx({ |
| 55 | + actor: { id: 'usr_1' }, |
| 56 | + conversationId: 'conv_1', |
| 57 | + confirmedBlueprintIdentity: DIGEST, |
| 58 | + }); |
| 59 | + |
| 60 | + expect(ctx.confirmedBlueprintIdentity).toBe(DIGEST); |
| 61 | + }); |
| 62 | + |
| 63 | + it('reaches a handler through the same context thread as every other field', () => { |
| 64 | + // Declaring the member is worthless if the only path a tool handler sees it |
| 65 | + // on does not carry it. This is that path. |
| 66 | + const options = asOptions({ |
| 67 | + toolExecutionContext: { confirmedBlueprintIdentity: DIGEST }, |
| 68 | + }); |
| 69 | + |
| 70 | + expect(options.toolExecutionContext?.confirmedBlueprintIdentity).toBe(DIGEST); |
| 71 | + }); |
| 72 | + |
| 73 | + it('is OPTIONAL, and absence reads as `undefined` rather than anything truthy', () => { |
| 74 | + // "No confirmed identity on this turn" is the resting state of every turn |
| 75 | + // that is not a confirm replay, so a context without the member must still |
| 76 | + // be a legal `ToolExecutionContext`. |
| 77 | + const none = asCtx({ actor: { id: 'usr_1' } }); |
| 78 | + |
| 79 | + expect(none.confirmedBlueprintIdentity).toBeUndefined(); |
| 80 | + // Control: the same read is lit on a context that does carry it, so the |
| 81 | + // `undefined` above is the ABSENT member and not a misspelled read. |
| 82 | + expect(asCtx({ confirmedBlueprintIdentity: DIGEST }).confirmedBlueprintIdentity).toBe(DIGEST); |
| 83 | + }); |
| 84 | + |
| 85 | + it('reads as `string | undefined`, so no handler can assume a confirmation', () => { |
| 86 | + const ctx = asCtx({ actor: { id: 'usr_1' } }); |
| 87 | + |
| 88 | + // @ts-expect-error — the member is optional on purpose: absence authorizes |
| 89 | + // nothing, and a consumer that narrows it to `string` without a check has |
| 90 | + // written exactly the path this contract forbids. Were the member ever made |
| 91 | + // required, this directive would stop detecting anything and TS2578 would |
| 92 | + // fail the test-layer type-check. |
| 93 | + const assumed: string = ctx.confirmedBlueprintIdentity; |
| 94 | + expect(assumed).toBeUndefined(); |
| 95 | + |
| 96 | + // Control: the guarded read — the shape a handler is meant to write — does |
| 97 | + // compile, so the leg above is refused for its missing check and not |
| 98 | + // because the member is unreadable. |
| 99 | + const guarded: string = ctx.confirmedBlueprintIdentity ?? ''; |
| 100 | + expect(guarded).toBe(''); |
| 101 | + }); |
| 102 | + |
| 103 | + it('is typed `string`, not `any` — a non-string is a compile error', () => { |
| 104 | + const wrong = asCtx({ |
| 105 | + // @ts-expect-error — a digest is a string. Typed `any` or `unknown` this |
| 106 | + // line would compile and the directive would go unused (TS2578), so this |
| 107 | + // leg also pins that the member did not arrive untyped. |
| 108 | + confirmedBlueprintIdentity: 42, |
| 109 | + }); |
| 110 | + |
| 111 | + expect(String(wrong.confirmedBlueprintIdentity)).toBe('42'); |
| 112 | + }); |
| 113 | + |
| 114 | + it('is pinned by its SPELLING — a near-miss is not the confirmation field', () => { |
| 115 | + const typo = asCtx({ |
| 116 | + // @ts-expect-error — the excess-property check refuses a member |
| 117 | + // `ToolExecutionContext` does not declare. The lit control is the first |
| 118 | + // leg above, where the correct spelling on the same helper is accepted. |
| 119 | + confirmedBlueprintIdentify: DIGEST, |
| 120 | + }); |
| 121 | + |
| 122 | + expect(typo.confirmedBlueprintIdentity).toBeUndefined(); |
| 123 | + }); |
| 124 | +}); |
0 commit comments