Skip to content

Commit af0edd8

Browse files
committed
Merge origin/main into claude/issue-18110-subflow-refused-rollup
Claude-Session: https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP Co-authored-by: Claude <noreply@anthropic.com>
2 parents 2ba9b83 + f8eaf67 commit af0edd8

6 files changed

Lines changed: 1339 additions & 2 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/scripts/lib/schema-section.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,42 @@ function externalVocabularyNote(prop: any): string {
259259
return ` (unit per ${standard.trim()})`;
260260
}
261261

262+
/**
263+
* The published half of the `dimensionless` exemption (#15676, ruling B on
264+
* #14478) — the sibling of {@link externalVocabularyNote}, and one grammar with
265+
* it rather than a second one.
266+
*
267+
* A key that carries `.meta({ dimensionless: '<what it counts>' })` is a count,
268+
* a multiplier or a ratio whose describe prose happens to name a time unit
269+
* belonging to something else in the sentence: `Failures seen in the last 5
270+
* minutes` is a number of failures, not a number of minutes. The marker rides
271+
* `z.toJSONSchema` verbatim, the same channel `externalVocabulary` / `xRef` /
272+
* `xExpression` / `xEnumDeprecated` use, so it arrives here as a property of
273+
* the JSON-Schema node.
274+
*
275+
* Printing it is what makes THIS exemption honest, on exactly the argument its
276+
* sibling rests on. `check:duration-unit-keys` exists because a naked number
277+
* beside prose naming a unit leaves the reader guessing; the rename is waived
278+
* here because the number HAS no unit, and that reason is invisible on the
279+
* page unless the page says it. A key exempted silently publishes the same
280+
* guess the gate was built to remove — with the marker printed, the answer is
281+
* stated: no unit, and here is what it counts instead.
282+
*
283+
* Both halves read the SAME declaration the gate reads — a non-empty string
284+
* literal — so an empty or non-string marker, which exempts no key there,
285+
* publishes nothing here. The page must never name a count the contract did
286+
* not.
287+
*
288+
* Appended to the description cell for the reason its sibling is: it qualifies
289+
* the prose already in that cell, and a marker on a handful of keys does not
290+
* earn a column on every table in the reference.
291+
*/
292+
function dimensionlessNote(prop: any): string {
293+
const counts = prop?.dimensionless;
294+
if (typeof counts !== 'string' || counts.trim() === '') return '';
295+
return ` (dimensionless — counts ${counts.trim()})`;
296+
}
297+
262298
/**
263299
* Render one schema's section, heading included.
264300
*
@@ -465,7 +501,9 @@ export function renderSchemaSection(schemaName: string, schema: any, ctx: Sectio
465501
// pipe), then pipes — an unescaped `|` (even inside a code span)
466502
// splits the cell.
467503
const desc = escapeMdxDescription(
468-
((prop.description || '') + externalVocabularyNote(prop)).replace(/\n/g, ' '),
504+
((prop.description || '')
505+
+ externalVocabularyNote(prop)
506+
+ dimensionlessNote(prop)).replace(/\n/g, ' '),
469507
)
470508
.replace(/\\/g, '\\\\')
471509
.replace(/\|/g, '\\|');

‎packages/spec/scripts/schema-section.test.ts‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,3 +657,118 @@ describe('externalVocabulary — the published half of the duration-rule exempti
657657
}
658658
});
659659
});
660+
661+
/**
662+
* [#18500] The PUBLISHED half of the `dimensionless` exemption — the sibling of
663+
* the block above, ruling B on #14478: the marker is one the gate honours AND
664+
* the docs generator publishes. `check:duration-unit-keys` shipped the reader
665+
* first; this is the other half.
666+
*
667+
* The argument is its sibling's, one step further. A bare `maxAge` leaves the
668+
* reference-page reader guessing seconds from milliseconds; a bare
669+
* `recentFailures` described as `Failures seen in the last 5 minutes` leaves
670+
* them guessing whether the number IS that span. The marker says it is not —
671+
* it counts failed attempts — and that reason reaches the page only if the
672+
* page prints it.
673+
*
674+
* The marker reaches this renderer as a property of the JSON-Schema node,
675+
* riding `z.toJSONSchema` verbatim (measured on zod 4.4.3 against
676+
* `z.number().meta({ dimensionless: 'failed attempts' })`: the key arrives on
677+
* the emitted node unchanged) — the same channel `externalVocabulary` / `xRef`
678+
* / `xExpression` / `xEnumDeprecated` use.
679+
*
680+
* MEASURED (reverse verification): deleting the `dimensionlessNote(prop)` term
681+
* from the description cell turns the first four cases below red and leaves the
682+
* last two green — the last two assert the note's ABSENCE, which is what keeps
683+
* it from decorating every row in the reference.
684+
*/
685+
describe('dimensionless — the published half of the duration-rule exemption', () => {
686+
const withMarker = (marker: unknown) => ({
687+
type: 'object',
688+
properties: {
689+
recentFailures: {
690+
type: 'number',
691+
description: 'Failures seen in the last 5 minutes',
692+
...(marker === undefined ? {} : { dimensionless: marker }),
693+
},
694+
},
695+
});
696+
697+
it('prints what the number counts, beside the prose whose unit belongs to something else', () => {
698+
const md = renderSchemaSection('HealthSignal', withMarker('failed attempts'));
699+
700+
expect(md).toContain(
701+
'Failures seen in the last 5 minutes (dimensionless — counts failed attempts)',
702+
);
703+
});
704+
705+
it('keeps the describe prose — the note QUALIFIES the number, it does not replace it', () => {
706+
const md = renderSchemaSection('BackoffPolicy', withMarker('retry attempts'));
707+
708+
expect(md).toContain('Failures seen in the last 5 minutes');
709+
expect(md).toContain('(dimensionless — counts retry attempts)');
710+
});
711+
712+
it('renders inside a nested shape table too — one grammar, not two', () => {
713+
const md = renderSchemaSection('RetryConfig', {
714+
type: 'object',
715+
properties: {
716+
backoff: {
717+
type: 'object',
718+
description: 'Backoff options',
719+
properties: {
720+
multiplier: {
721+
type: 'number',
722+
description: 'Growth applied to the previous 30 second delay',
723+
dimensionless: 'the factor each delay is multiplied by',
724+
},
725+
},
726+
},
727+
},
728+
});
729+
730+
expect(md).toContain(
731+
'Growth applied to the previous 30 second delay (dimensionless — counts the factor each delay is multiplied by)',
732+
);
733+
});
734+
735+
it('composes with the sibling marker — a key declaring both publishes both reasons', () => {
736+
// Nothing in the gate makes the two exemptions exclusive, so the cell is
737+
// built by appending both notes rather than choosing between them. Pinned
738+
// because a renderer that picked one would look identical on every key that
739+
// carries only one.
740+
const md = renderSchemaSection('CacheStats', {
741+
type: 'object',
742+
properties: {
743+
maxAge: {
744+
type: 'number',
745+
description: 'Entries seen in the last 60 seconds',
746+
externalVocabulary: 'HTTP Cache-Control `max-age`',
747+
dimensionless: 'cached entries',
748+
},
749+
},
750+
});
751+
752+
expect(md).toContain(
753+
'Entries seen in the last 60 seconds (unit per HTTP Cache-Control `max-age`) (dimensionless — counts cached entries)',
754+
);
755+
});
756+
757+
it('prints nothing for a key that declares no marker — the note is not decoration', () => {
758+
const md = renderSchemaSection('HealthSignal', withMarker(undefined));
759+
760+
expect(md).toContain('Failures seen in the last 5 minutes');
761+
expect(md).not.toContain('dimensionless');
762+
});
763+
764+
it('prints nothing for an empty or non-string marker — an unverifiable claim publishes nothing', () => {
765+
// The gate refuses these too (they exempt no key), so the page must not
766+
// name a count the contract never declared. Held on the SAME inputs from
767+
// both sides so the two halves cannot drift into disagreeing about what
768+
// counts as a declaration.
769+
for (const marker of ['', ' ', 42, null, { counts: 'failed attempts' }]) {
770+
const md = renderSchemaSection('HealthSignal', withMarker(marker));
771+
expect(md, `marker ${JSON.stringify(marker)}`).not.toContain('dimensionless');
772+
}
773+
});
774+
});

‎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)