diff --git a/.changeset/15937-confirmed-blueprint-identity-protocol.md b/.changeset/15937-confirmed-blueprint-identity-protocol.md new file mode 100644 index 00000000000..b429de1d117 --- /dev/null +++ b/.changeset/15937-confirmed-blueprint-identity-protocol.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": minor +--- + +`ToolExecutionContext.confirmedBlueprintIdentity` — the consent digest a route-owning layer stamps on a confirm replay — is now declared in the protocol instead of in one consumer's augmented type (#15937). + +Clause-②: yes (widening) — one new OPTIONAL member on a published interface, so the shape a consumer writes against grows. Nothing previously admitted is refused, no member is renamed or retired, and no producer is required to write it. Contract-review tier. + +`packages/spec/src/contracts/ai-service.ts` declares the tool-execution context a tool handler may rely on. A published handler in `objectstack-ai/cloud` — the `apply_blueprint` authorization gate — already makes a matching blueprint-identity digest one clause of the decision to build a whole app (cloud#1954 / cloud PR #2005), but the member it reads was declared only on cloud's own augmented `ToolExecutionContext` and reached by a structural cast. The protocol is this project's baseline, so a field a handler authorizes on is declared here. + +- **The member is optional and fail-closed.** `undefined` means "no confirmed identity on this turn" and authorizes nothing — the same reading `actor` and `isSystem` already carry (#2991): absence is never a grant. The docblock states it, and the type enforces the handler-side half of it, because a read of `string | undefined` does not compile into a path that assumes a confirmation. +- **Provenance is part of the declaration**, in the shape `userMessageText` already carries: populated by whichever layer owns the agent route (cloud, post-cloud ADR-0025), only ever by in-process server code on that route, and never derived from a request body, a tool argument or the transcript. +- **Nothing in this repository reads it yet**, and nothing here changes behaviour: this is the declaration half. Deleting cloud's augmentation and replacing its cast with the typed read is a cloud follow-up, blocked on this field being published and pinned. +- **The contract is now asserted.** `confirmed-blueprint-identity-contract.pin.test.ts` pins that the member lives on `ToolExecutionContext`, reaches a handler through `ChatWithToolsOptions.toolExecutionContext`, stays optional, and is typed `string` — each negative leg paired with a positive one on the same helper, so a leg that stops detecting anything turns the test-layer type-check red rather than passing quietly. diff --git a/packages/spec/src/contracts/ai-service.ts b/packages/spec/src/contracts/ai-service.ts index 09a8849ad6c..bf79a0cc3f6 100644 --- a/packages/spec/src/contracts/ai-service.ts +++ b/packages/spec/src/contracts/ai-service.ts @@ -627,6 +627,28 @@ export interface ToolExecutionContext { * Populated by whichever layer owns the agent route (cloud, post-ADR-0025). */ userMessageText?: string; + /** + * Blueprint-identity digest stamped on a CONFIRM REPLAY of an already + * approved proposal — the consent record saying *this exact blueprint* is + * the one the user approved, carried onto the turn that replays it. + * Populated by whichever layer owns the agent route (cloud, post-cloud + * ADR-0025) — the same provenance {@link userMessageText} carries — and set + * only by in-process server code on that route. ⛔ NEVER derived from a + * request body, a tool argument or the transcript: anything the caller can + * write is not a confirmation. + * + * `undefined` means "no confirmed identity on this turn" and authorizes + * NOTHING — the same fail-closed reading as {@link actor} and + * {@link isSystem} (#2991). A handler MUST NOT read consent out of a + * missing field. + * + * Declared here because a published handler already AUTHORIZES on it: + * cloud's `apply_blueprint` gate makes a matching identity one clause of + * the decision to build a whole app (cloud#1954, cloud PR #2005). A field a + * handler authorizes on belongs in the declared contract, not in a + * consumer's structural cast. + */ + confirmedBlueprintIdentity?: string; /** Distributed-trace id for cross-service correlation. */ traceId?: string; /** diff --git a/packages/spec/src/contracts/confirmed-blueprint-identity-contract.pin.test.ts b/packages/spec/src/contracts/confirmed-blueprint-identity-contract.pin.test.ts new file mode 100644 index 00000000000..7d011893e94 --- /dev/null +++ b/packages/spec/src/contracts/confirmed-blueprint-identity-contract.pin.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15937] `ToolExecutionContext.confirmedBlueprintIdentity`, pinned where a + * grep can find it. + * + * The field arrives in the protocol because a PUBLISHED handler already + * authorizes on it: cloud's `apply_blueprint` gate makes a matching + * blueprint-identity digest one clause of the decision to build a whole app + * (cloud#1954 / cloud PR #2005), and until this card it read the value off the + * execution context through a structural cast against a declaration that lived + * only in that one consumer's augmented type. A consent field a handler + * authorizes on belongs in the declared contract. + * + * ## What is pinned, and what is deliberately NOT + * + * Pinned: that the member lives on `ToolExecutionContext` itself (not on some + * neighbouring options bag), that it reaches a handler through the same + * `ChatWithToolsOptions.toolExecutionContext` thread every other field uses, + * that it is OPTIONAL and reads back `undefined` when absent, that its read + * type is `string | undefined` rather than `string` — so a handler cannot + * compile a path that assumes a confirmation is always present — and that it + * is typed `string` rather than `any`, which is what makes the two + * `@ts-expect-error` legs below detect anything at all. + * + * NOT pinned: any authorization behaviour. The gate that consumes this field + * lives in `objectstack-ai/cloud` and is not in this tree, so a behavioural + * assertion here would be a stub asserting itself. This file pins the + * DECLARATION half, which is the half this repository owns. + * + * ## Why every leg carries a control + * + * A `@ts-expect-error` is only a check while the line under it really is an + * error: an unused directive is itself a `tsc` error (TS2578) under + * `packages/spec`'s test-layer program, so each negative leg goes red — rather + * than quietly green — the day the type stops refusing what it names. The + * positive legs are the other half of the same pair: the near-miss spelling is + * refused only because the correct spelling on the line above is accepted. + */ + +import { describe, it, expect } from 'vitest'; + +import type { ChatWithToolsOptions, ToolExecutionContext } from './ai-service'; + +/** Identity helper, so a rejected shape errors on the argument's own line. */ +const asCtx = (c: ToolExecutionContext) => c; +const asOptions = (o: ChatWithToolsOptions) => o; + +/** A plausible digest — the shape cloud stamps, opaque to this contract. */ +const DIGEST = 'sha256:3f6a1c0e9b2d4a7f8c5e1b0d9a2f4c6e8b0d3a5f7c9e1b3d5a7f9c1e3b5d7a9f'; + +describe('confirmedBlueprintIdentity contract (#15937)', () => { + it('is declared on `ToolExecutionContext` and round-trips the digest', () => { + const ctx = asCtx({ + actor: { id: 'usr_1' }, + conversationId: 'conv_1', + confirmedBlueprintIdentity: DIGEST, + }); + + expect(ctx.confirmedBlueprintIdentity).toBe(DIGEST); + }); + + it('reaches a handler through the same context thread as every other field', () => { + // Declaring the member is worthless if the only path a tool handler sees it + // on does not carry it. This is that path. + const options = asOptions({ + toolExecutionContext: { confirmedBlueprintIdentity: DIGEST }, + }); + + expect(options.toolExecutionContext?.confirmedBlueprintIdentity).toBe(DIGEST); + }); + + it('is OPTIONAL, and absence reads as `undefined` rather than anything truthy', () => { + // "No confirmed identity on this turn" is the resting state of every turn + // that is not a confirm replay, so a context without the member must still + // be a legal `ToolExecutionContext`. + const none = asCtx({ actor: { id: 'usr_1' } }); + + expect(none.confirmedBlueprintIdentity).toBeUndefined(); + // Control: the same read is lit on a context that does carry it, so the + // `undefined` above is the ABSENT member and not a misspelled read. + expect(asCtx({ confirmedBlueprintIdentity: DIGEST }).confirmedBlueprintIdentity).toBe(DIGEST); + }); + + it('reads as `string | undefined`, so no handler can assume a confirmation', () => { + const ctx = asCtx({ actor: { id: 'usr_1' } }); + + // @ts-expect-error — the member is optional on purpose: absence authorizes + // nothing, and a consumer that narrows it to `string` without a check has + // written exactly the path this contract forbids. Were the member ever made + // required, this directive would stop detecting anything and TS2578 would + // fail the test-layer type-check. + const assumed: string = ctx.confirmedBlueprintIdentity; + expect(assumed).toBeUndefined(); + + // Control: the guarded read — the shape a handler is meant to write — does + // compile, so the leg above is refused for its missing check and not + // because the member is unreadable. + const guarded: string = ctx.confirmedBlueprintIdentity ?? ''; + expect(guarded).toBe(''); + }); + + it('is typed `string`, not `any` — a non-string is a compile error', () => { + const wrong = asCtx({ + // @ts-expect-error — a digest is a string. Typed `any` or `unknown` this + // line would compile and the directive would go unused (TS2578), so this + // leg also pins that the member did not arrive untyped. + confirmedBlueprintIdentity: 42, + }); + + expect(String(wrong.confirmedBlueprintIdentity)).toBe('42'); + }); + + it('is pinned by its SPELLING — a near-miss is not the confirmation field', () => { + const typo = asCtx({ + // @ts-expect-error — the excess-property check refuses a member + // `ToolExecutionContext` does not declare. The lit control is the first + // leg above, where the correct spelling on the same helper is accepted. + confirmedBlueprintIdentify: DIGEST, + }); + + expect(typo.confirmedBlueprintIdentity).toBeUndefined(); + }); +});