Skip to content

Commit c7d4825

Browse files
os-litantclaude
andauthored
spec: declare confirmedBlueprintIdentity on the protocol's ToolExecutionContext (#18676)
Fixes #15937 `ToolExecutionContext` in `packages/spec/src/contracts/ai-service.ts` is the contract a tool handler may rely on. A **published handler already authorizes on** `confirmedBlueprintIdentity` — 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) — while the member it reads was declared only on cloud's own augmented `ToolExecutionContext` and reached by a structural cast. This declares it where the contract lives. 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. The `needs:contract-review` carrier is the seat's on both sides and this PR touches neither. ## The ruling this implements The maintainer's ruling is recorded on #15937 at comment 5553002827 (2026-09-05), on option 1, verbatim 「同意」. A triage stroke the next morning re-hung `needs-user-decision` on an already-decided card, and the director seat reversed it at 5559258277 with "Nothing to re-decide". So the implementer's brief is the ruling comment, not the card body's two-option analysis. Ruling items 1 and 2 are implemented here. **Item 3 is a read-and-report and is answered below.** **Item 4** — delete cloud's augmentation at `tool-registry.ts:45`, replace the cast in `confirm-gate.ts` with the typed read — is a cloud follow-up card blocked on this field being published and pinned. It is not in this diff, and this seat neither touched cloud nor filed that card. ## What landed **`ToolExecutionContext.confirmedBlueprintIdentity?: string`**, placed next to `userMessageText` because that is the same class of field: route-owner-populated, advisory-to-nobody, never client-derived. Its docblock carries the four things item 1 asks for. - The digest is stamped on a **confirm replay** of an already approved proposal — the consent record saying *this exact blueprint* is the one the user approved. - Populated by whichever layer owns the agent route (cloud, post-cloud ADR-0025), and **only by in-process server code on that route**; never derived from a request body, a tool argument or the transcript. - `undefined` means "no confirmed identity on this turn" and **authorizes nothing** — the same fail-closed reading `actor` and `isSystem` already carry (#2991). - The consumer is cited by name: cloud#1954 / cloud PR #2005. **`confirmed-blueprint-identity-contract.pin.test.ts`** — a new pin, following the file's existing `action-confirmation-contract.pin.test.ts` convention. `ToolExecutionContext` had **no test naming it anywhere in this repository** before this PR (`git grep -l ToolExecutionContext` over the tree lit 8 files: two CHANGELOGs, two generated artefacts, one consumer, the contract itself — and zero test files). Six legs, each negative one paired with a lit positive one on the same helper: | leg | what it pins | what makes it able to fail | |---|---|---| | declared on `ToolExecutionContext` | the member, and the digest round-trips | removing the member makes the literal an excess property | | reaches a handler | it travels on `ChatWithToolsOptions.toolExecutionContext` | declaring it on the wrong type compiles nowhere | | optional, absent reads `undefined` | absence is the resting state of a non-replay turn | the lit control reads the same property on a context that carries it | | reads as `string \| undefined` | a handler cannot compile a path that assumes a confirmation | making the member required leaves the directive unused — TS2578 | | typed `string`, not `any` | the member did not arrive untyped | typing it `any`/`unknown` leaves the directive unused — TS2578 | | the spelling is load-bearing | a near-miss is not the confirmation field | renaming the real member reddens the first leg instead | **Changeset**: `@objectstack/spec` **minor**, per ruling item 2. ## Item 3 — `systemInvocation`: read, reported, NOT folded in Measured on this base, repo-wide with same-scope controls: | target | files | reading | |---|---|---| | `systemInvocation` | **0** | the protocol does not declare it either | | lit control `userMessageText` | 1 | the grep reaches, and finds the analogue field | | lit control `isSystem` | 853 | the wide control | | dark control | 0 | — | **Half of the class holds and half is not measurable from here.** The class this card names has two halves: (a) absent from the protocol, and (b) load-bearing for an authorization decision a published handler makes through a structural cast. (a) is confirmed above. (b) lives in `objectstack-ai/cloud`, which is outside this card's scope and which this seat did not open — so it is reported as unmeasured rather than assumed. What the protocol side does say is an asymmetry worth handing to whoever picks this up: **`confirmedBlueprintIdentity` had no protocol counterpart at all**, whereas `systemInvocation` sits beside `isSystem`, which this same interface declares and whose docblock calls it "the ONLY way to obtain system behaviour from the tool loop". So the first question for `systemInvocation` is not "declare it" but "does it duplicate `isSystem`" — and only a "no" makes it this card's case. That is a different first question, so it is **not plainly the same class of omission**, it needs its own card, and this PR does not widen into it. ## Premise re-check on this base The dispatch flagged three premises. All three were re-measured rather than inherited. 1. **The gap is still open.** On base `30be2ac0bb`: `confirmedBlueprintIdentity` across `packages/` = **0 files** (grep exit 1); lit control `userMessageText` = **1 file**; lit control `ToolExecutionContext` = **8 files**; dark control = 0. The one-file control proves the grep reaches `packages/` and finds the analogue field and **nothing more** — the 8-file control is the wide one. 2. **The file moved after the ruling, the interface did not.** `scripts/pm/git-history.mjs touch` answers `fe0d9a4241ab782628e5a7bc4ac61baeb505fbd1`, 2026-09-07T10:00:37Z, +134 lines — two days after the 2026-09-05 ruling, and proved without fetching, the touch and its parent sitting above the shallow floor. Reading that commit's own diff: **not one added or removed line names a `ToolExecutionContext` member**. The +134 is the action-confirmation contract (#16531) elsewhere in the file. So the interface and `userMessageText`'s docblock were re-read on this base and the docblock matched in shape is the one on the tree; the ruling's quoted field list still describes it, its "UI-context block" being `currentObjectName` / `currentViewName` / `surfaceContext`. 3. **The card's comparison holds.** cloud PR #2005's body placed `confirmedBlueprintIdentity` "alongside `userMessageText` and `systemInvocation`". Of those three, `userMessageText`'s single repo-wide hit **is** this spec file — the protocol, not an augmentation — while `systemInvocation` has zero. The three were never alike in where they are declared, which is the whole reason this card exists. ## Verification Readings are cited at the commit they were taken on. `pnpm lint` (the repo-wide `eslint . --no-inline-config`) is CI's run; what is below is a declared narrowing with its evidence. - **Build** — `pnpm --filter @objectstack/spec build`, exit 0. `packages/spec/dist/contracts/index.d.ts` carries the member, so the readings below are against a rebuilt tree, not a cached one. `git status` clean after the build: no generator moved a tracked artefact, and `api-surface/contracts.json` is unmoved because it records exported symbol names, not interface members. - **Type-check** — `pnpm --filter @objectstack/spec typecheck`, exit 0 (that is `tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck` in series). The test-layer ratchet holds at 54 files / 259 errors / 144 pinned signatures, unchanged. - **Tests** — `pnpm --filter @objectstack/spec test`, exit 0: **486 files, 13856 tests passed**. The new pin, run on its own, is 6 passed. - **Ablation, direction predicted before the run: RED.** From the committed state, the declared member was renamed on disk (delete-anchor 1 to 0, inject-anchor 0 to 1, blob hash moved `bf79a0cc` to `a646979419`), and `check:test-typecheck` went from exit 0 to **exit 1, naming 10 type errors in the new pin file**. The restore leg put HEAD's bytes back — `git hash-object` back to `bf79a0cc3f6fb0505e0edf6c5665c3f004e371f4`, `git diff HEAD` empty, `git status --porcelain` empty — and the same gate returned to exit 0. Both legs ran under a `trap ... EXIT INT TERM` restore with absolute paths. No `dist` preflight: the pin imports `./ai-service` relatively, so it resolves to `src`, and a `dist` reading would be about the wrong artefact. - **Lint, narrowed and declared**, at `5ddd6f45ee`. ① The population comes from eslint's own config: the base block is `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`, so the changeset `.md` is outside it entirely — eslint says so itself, "File ignored because no matching configuration was supplied". ② `--format json` counts 3 entries, of which 2 are in-population: **0 errors, 0 warnings** on both `.ts` files. ③ Invariance, quoted from `eslint.config.mjs`: "this repo runs one `eslint.config.mjs`, which never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file, test or not." With no type-aware rule, nothing in this diff can move the verdict on a file the diff does not touch; the config's file rosters are likewise untouched. - **Control bytes** — `check:nul-bytes` exit 0 over 8798 files, plus a direct `grep -naP` sweep of the three changed files, which found none. The full re-derived gate sweep, every exit code, and the reconciliation against `dispatch-gates.mjs --ran` are in the `os-dev-report` comment on #15937. ## Acceptance notes Noted while in the file, **not filed and not fixed here**: - `userMessageText`'s docblock, the line directly above the new member, reads "(cloud, post-ADR-0025)" — a **bare** ADR number for what is, in context, cloud's record. AGENTS.md Prime Directive 13 is explicit: an ADR lives in the repository whose code it governs, cloud decisions are cited "as `cloud ADR-NNNN` — ⛔ never as a bare number, which `scripts/check-adr-anchors.mjs` resolves against *this* registry (the two number independently)". This repository's own `ADR-0025` is `docs/adr/0025-plugin-package-distribution.md`, and every other bare `ADR-0025` in the tree means that one, so a reader following this citation lands on a real page about plugin packaging. The new member spells it `cloud ADR-0025`, which is why the two lines differ. **Not folded in**: it is a different defect class from this card's, so the bounded in-place exemption does not open, and `check:adr-anchors` does not read source docblocks at all (it checks only the entries under `scripts/adr-anchors/`), so nothing is currently red. Reported with dedupe words in the report comment for the triage seat to file or discard. - `ToolExecutionContext` carrying **no test at all** before this PR is now half-closed: the new pin asserts the new member and the thread it travels on, deliberately not the other twelve members. Widening it to the whole interface would be scope this card does not carry. Provenance: authored by an `os-dev` seat under the PM dispatch on #15937, session `session_01LvwGppdonww4zGLWZo5rho` (https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho). --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 17965df commit c7d4825

3 files changed

Lines changed: 160 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`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).
6+
7+
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.
8+
9+
`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.
10+
11+
- **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.
12+
- **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.
13+
- **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.
14+
- **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.

‎packages/spec/src/contracts/ai-service.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,28 @@ export interface ToolExecutionContext {
627627
* Populated by whichever layer owns the agent route (cloud, post-ADR-0025).
628628
*/
629629
userMessageText?: string;
630+
/**
631+
* Blueprint-identity digest stamped on a CONFIRM REPLAY of an already
632+
* approved proposal — the consent record saying *this exact blueprint* is
633+
* the one the user approved, carried onto the turn that replays it.
634+
* Populated by whichever layer owns the agent route (cloud, post-cloud
635+
* ADR-0025) — the same provenance {@link userMessageText} carries — and set
636+
* only by in-process server code on that route. ⛔ NEVER derived from a
637+
* request body, a tool argument or the transcript: anything the caller can
638+
* write is not a confirmation.
639+
*
640+
* `undefined` means "no confirmed identity on this turn" and authorizes
641+
* NOTHING — the same fail-closed reading as {@link actor} and
642+
* {@link isSystem} (#2991). A handler MUST NOT read consent out of a
643+
* missing field.
644+
*
645+
* Declared here because a published handler already AUTHORIZES on it:
646+
* cloud's `apply_blueprint` gate makes a matching identity one clause of
647+
* the decision to build a whole app (cloud#1954, cloud PR #2005). A field a
648+
* handler authorizes on belongs in the declared contract, not in a
649+
* consumer's structural cast.
650+
*/
651+
confirmedBlueprintIdentity?: string;
630652
/** Distributed-trace id for cross-service correlation. */
631653
traceId?: string;
632654
/**
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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

Comments
 (0)