Skip to content

Commit ae3b1c8

Browse files
committed
fix: preserve exact Proposal inputs and bounded workflow metadata
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
1 parent d6777a3 commit ae3b1c8

24 files changed

Lines changed: 565 additions & 9 deletions

‎crates/registry-breg-client-node/README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,3 +296,20 @@ cache; `await provider.bearerToken()` uses that separate client-credentials
296296
cache. Neither method logs or persists the returned credential. The application
297297
must protect it and pass it explicitly to a resource client. The resource server
298298
still validates the signed authority and bounds.
299+
300+
## Exact JSON Proposal transitions and listing
301+
302+
Use `prepareProposalJson(proposal, operation, bodyJson, key)` when an edit
303+
contains integers outside JavaScript's safe integer range, including nested
304+
values that satisfy BReg's binary64 mutation model. Exact reads retain all
305+
signed and unsigned 64-bit integers, while mutation preparation still refuses
306+
integers that model cannot represent exactly. Decimal text such as `"0.7500"` stays a string with its lexical form.
307+
The exact JSON decoder refuses duplicate members and lossy numeric literals.
308+
The structured `prepareProposal` method continues to refuse unsafe numbers.
309+
310+
`listProposalsJson(collection, queryJson)` accepts the same query contract as
311+
`listProposals`, with explicit pagination and a metadata-selected collection.
312+
Both methods delegate to the canonical Rust client. They retain the same
313+
pre-I/O checks, lifecycle conditions, source binding, and caller-chosen key.
314+
Persist and recover the returned `BRegPreparedProposal` through
315+
`recoverProposal` before executing after a restart, just as for an object body.

‎crates/registry-breg-client-node/__test__/facade.test-d.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
BRegRecoveredCreate,
1515
BRegProposal,
1616
BRegProposalGetBinding,
17+
BRegProposalCollection,
1718
BRegPreparedProposal,
1819
BRegTombstoneBinding,
1920
AsOfListContinuation,
@@ -59,6 +60,13 @@ declare const preparedAction: BRegPreparedAction
5960
declare const preparedCreate: BRegPreparedCreate
6061
declare const proposal: BRegProposal
6162
declare const proposalGet: BRegProposalGetBinding
63+
declare const proposalCollection: BRegProposalCollection
64+
client.listProposalsJson(proposalCollection, '{"limit":1}')
65+
client.prepareProposalJson(proposal, 'edit', '{"input":{"wide":9223372036854775807}}', 'exact-key')
66+
// @ts-expect-error Exact Proposal methods require JSON text.
67+
client.prepareProposalJson(proposal, 'edit', {input: {}}, 'exact-key')
68+
// @ts-expect-error Exact Proposal listing requires JSON text.
69+
client.listProposalsJson(proposalCollection, {})
6270
declare const preparedProposal: BRegPreparedProposal
6371
declare const recoveredAction: BRegRecoveredAction
6472
declare const recoveredCreate: BRegRecoveredCreate
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
'use strict';
2+
const assert = require('node:assert/strict');
3+
const http = require('node:http');
4+
const { test } = require('node:test');
5+
const { BaseRegistryClient, BRegPreparedProposal } = process.env.BREG_CLIENT_PACKAGE
6+
? require(process.env.BREG_CLIENT_PACKAGE).breg : require('..');
7+
8+
const id = '00000000-0000-4000-8000-000000000001';
9+
const revision = `sha256:${'a'.repeat(64)}`;
10+
const metadata = {id: 'proposal-json', version: '1', revision, metadataVersion: '2', recordTypes: [], operations: [],
11+
proposals: {method: 'GET', path: '/v1/proposals', accessProfiles: ['author'],
12+
actions: [{actionId: 'correct', accessProfile: 'author', operations: ['get', 'list'], readableInputs: [], readableContextFields: []}],
13+
get: {method: 'GET', path: '/v1/proposals/{proposal_id}', accessProfiles: ['author']}}};
14+
const proposal = {id, actionId: 'correct', actionRevision: revision, submissionVersion: 1, revision: 1, state: 'draft', links: {
15+
self: {method: 'GET', path: `/v1/proposals/${id}`, accessProfile: 'author', operation: 'get'},
16+
edit: {method: 'POST', path: `/v1/proposals/${id}/edit`, accessProfile: 'author', operation: 'edit', ifMatch: '"draft-condition"'},
17+
}};
18+
const inputJson = '{"nullable":null,"nested":{"wide":9223372036854775807,"decimal":"0.7500","values":[9223372036854775807,null,{"amount":"0.7500"}]}}';
19+
20+
test('exact Proposal JSON methods preserve nested values and recovery authority through native HTTP', async () => {
21+
const requests = [];
22+
let current = JSON.stringify(proposal).replace('"links":', `"input":${inputJson},"links":`);
23+
const server = http.createServer(async (request, response) => {
24+
let body = ''; for await (const chunk of request) body += chunk;
25+
requests.push({url: request.url, body, headers: request.headers});
26+
response.writeHead(200, {'content-type': 'application/json', 'cache-control': 'no-store', vary: 'authorization, accept',
27+
traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'});
28+
if (request.url.startsWith('/tenant/v1/registry')) return response.end(JSON.stringify(metadata));
29+
if (request.url.startsWith('/tenant/v1/proposals?')) return response.end(`{"items":[${current}],"nextCursor":"next-page"}`);
30+
if (request.url.includes('/edit?')) {
31+
current = current.replace('"revision":1', '"revision":2').replace(inputJson, body.slice('{"input":'.length, -1));
32+
}
33+
response.end(current);
34+
});
35+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
36+
try {
37+
const client = new BaseRegistryClient({baseUrl: `http://127.0.0.1:${server.address().port}/tenant/`});
38+
assert.equal(typeof client.prepareProposalJson, 'function');
39+
assert.equal(typeof client.listProposalsJson, 'function');
40+
const contract = await client.registryContract('author');
41+
const collection = contract.selectProposals('author');
42+
const page = await client.listProposalsJson(collection, '{"action":"correct","limit":1}');
43+
assert.equal(page.nextCursor, 'next-page');
44+
assert.match(page.items[0].inputJson, /9223372036854775807/);
45+
assert.match(page.items[0].inputJson, /"decimal":"0.7500"/);
46+
const url = new URL(requests.at(-1).url, 'http://fixture.invalid');
47+
assert.equal(url.pathname, '/tenant/v1/proposals');
48+
assert.equal(url.searchParams.get('accessProfile'), 'author');
49+
assert.equal(url.searchParams.get('action'), 'correct');
50+
assert.equal(url.searchParams.get('limit'), '1');
51+
const draft = page.items[0];
52+
const before = requests.length;
53+
for (const body of ['{"input":{"wide":1,"wide":2}}', '{"input":{"wide":1e9999}}', '{"input":[]}', '[]']) {
54+
assert.throws(() => client.prepareProposalJson(draft, 'edit', body, 'invalid'), error => error.kind === 'invalid-request' && !error.outcomeUnknown);
55+
}
56+
assert.throws(() => client.prepareProposalJson(draft, 'apply', '{}', 'illegal-transition'), error => error.kind === 'invalid-request');
57+
assert.throws(() => client.prepareProposal(draft, 'edit', {input: {wide: Number.MAX_SAFE_INTEGER + 1}}, 'unsafe'), error => error.kind === 'invalid-request');
58+
for (const query of ['{"limit":1,"limit":2}', '{"limit":0}', '{"unknown":true}', '{"limit":"one"}']) {
59+
await assert.rejects(client.listProposalsJson(collection, query), error => error.kind === 'invalid-request', query);
60+
}
61+
assert.equal(requests.length, before, 'invalid JSON, query, structured unsafe numbers and transitions fail before I/O');
62+
assert.throws(() => client.prepareProposalJson(draft, 'edit', `{"input":${inputJson}}`, 'inexact-binary64'), error => error.kind === 'invalid-request');
63+
const exactEditInputJson = inputJson.replaceAll('9223372036854775807', '9007199254740992');
64+
const attempt = client.prepareProposalJson(draft, 'edit', `{"input":${exactEditInputJson}}`, 'exact-edit-key');
65+
const bytes = attempt.toBytes();
66+
const restored = BRegPreparedProposal.fromBytes(bytes);
67+
await assert.rejects(client.executeProposal(restored), error => error.kind === 'invalid-request');
68+
assert.equal(requests.length, before, 'persisted evidence requires fresh matching Proposal authority');
69+
const recovered = client.recoverProposal(await client.readProposal(draft), restored);
70+
assert.deepEqual(recovered.toBytes(), bytes);
71+
const edited = await client.executeProposal(recovered);
72+
assert.match(edited.inputJson, /9007199254740992/);
73+
const sent = requests.at(-1);
74+
assert.equal(sent.url, `/tenant/v1/proposals/${id}/edit?accessProfile=author`);
75+
assert.equal(sent.headers['idempotency-key'], 'exact-edit-key');
76+
assert.equal(sent.headers['if-match'], '"draft-condition"');
77+
assert.match(sent.body, /"wide":9007199254740992/);
78+
assert.match(sent.body, /"decimal":"0.7500"/);
79+
assert.match(sent.body, /"values":\[9007199254740992,null,\{"amount":"0.7500"\}\]/);
80+
assert.equal(JSON.parse(sent.body).input.nullable, null);
81+
assert.ok(!Object.hasOwn(JSON.parse(sent.body).input, 'absent'));
82+
} finally { await new Promise(resolve => server.close(resolve)); }
83+
});

‎crates/registry-breg-client-node/client.d.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,8 @@ export declare class BaseRegistryClient {
887887
/** Read one retained submission version under the access profile of the Proposal's self link. */
888888
readProposalSubmission(proposal: BRegProposal, version: number): Promise<CompleteOutcome<BRegProposalSubmission>>
889889
listProposals(collection: BRegProposalCollection, query: JsonObject): Promise<BRegProposalPage>
890+
/** Exact JSON query; retains the metadata-selected collection and bounded pagination. */
891+
listProposalsJson(collection: BRegProposalCollection, queryJson: string): Promise<BRegProposalPage>
890892
/**
891893
* Bind an available transition without I/O. Throws locally when the operation
892894
* is not a legal transition from the Proposal's observed state; `cancel` is
@@ -895,6 +897,8 @@ export declare class BaseRegistryClient {
895897
* exercise the server's terminal-state refusal.
896898
*/
897899
prepareProposal(proposal: BRegProposal, operation: BRegProposalTransition, body: JsonObject, key: string): BRegPreparedProposal
900+
/** Exact JSON body; preserves integer values and the same lifecycle authority and recovery contract. */
901+
prepareProposalJson(proposal: BRegProposal, operation: BRegProposalTransition, bodyJson: string, key: string): BRegPreparedProposal
898902
recoverProposal(proposal: BRegProposal, attempt: BRegPreparedProposal): BRegPreparedProposal
899903
executeProposal(attempt: BRegPreparedProposal): Promise<BRegProposal>
900904
constructor(config: BaseRegistryClientConfig)

‎crates/registry-breg-client-node/client.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ for (const [method, jsonIndexes, requiredIndexes] of [
232232
['invokeAction', [1], [1]],
233233
['executeRecoveredAction'], ['executeRecoveredProposalStart'], ['executeRecoveredCreate'],
234234
['patchRecord', [3], [3]], ['batchRecords', [1], [1]], ['tombstoneRecord'],
235-
['startProposal', [1], [1]], ['readProposal'], ['readProposalSubmission'], ['getProposal'], ['listProposals', [1], [1]], ['executeProposal'],
235+
['startProposal', [1], [1]], ['readProposal'], ['readProposalSubmission'], ['getProposal'], ['listProposals', [1], [1]], ['executeProposal'], ['listProposalsJson'],
236236
['downloadProposalAttachment'], ['uploadProposalAttachment'], ['deleteProposalAttachment'],
237237
['readProposalReviewStatus'], ['readProposalReviewContext'],
238238
['readProposalReviewContextBatch', [1], [1]],
@@ -256,7 +256,7 @@ for (const [method, jsonIndexes, requiredIndexes] of [
256256
['prepareAction', [1], [1]], ['prepareActionJson'],
257257
['recoverAction', [2], [2]], ['recoverActionJson'],
258258
['prepareCreate', [1], [1]], ['prepareCreateJson'], ['recoverCreate'],
259-
['prepareProposal', [2], [2]], ['recoverProposal'],
259+
['prepareProposal', [2], [2]], ['prepareProposalJson'], ['recoverProposal'],
260260
]) {
261261
const original = native.BaseRegistryClient.prototype[method];
262262
native.BaseRegistryClient.prototype[method] = function (...args) {

‎crates/registry-breg-client-node/index.d.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ export declare class BaseRegistryClient {
6363
deleteProposalAttachment(proposal: BRegProposal, slot: string, key: string): Promise<BRegProposal>
6464
listProposals(collection: BRegProposalCollection, query: any): Promise<ProposalPage>
6565
prepareProposal(proposal: BRegProposal, operation: string, body: any, key: string): BRegPreparedProposal
66+
/** Prepare a transition from exact JSON through the same lifecycle authority. */
67+
prepareProposalJson(proposal: BRegProposal, operation: string, bodyJson: string, key: string): BRegPreparedProposal
68+
/** List through the same metadata-selected collection with an exact JSON query. */
69+
listProposalsJson(collection: BRegProposalCollection, queryJson: string): Promise<ProposalPage>
6670
recoverProposal(proposal: BRegProposal, attempt: BRegPreparedProposal): BRegPreparedProposal
6771
executeProposal(attempt: BRegPreparedProposal): Promise<BRegProposal>
6872
/** Fetch opaque target conditions for one metadata-selected action. */

‎crates/registry-breg-client-node/src/lib.rs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3393,6 +3393,29 @@ impl BaseRegistryClient {
33933393
.map_err(client_error)
33943394
}
33953395

3396+
/// Prepare a transition from exact JSON through the same lifecycle authority.
3397+
#[napi]
3398+
pub fn prepare_proposal_json(
3399+
&self,
3400+
proposal: &Proposal,
3401+
operation: String,
3402+
body_json: String,
3403+
key: String,
3404+
) -> Result<PreparedProposal> {
3405+
self.prepare_proposal(proposal, operation, exact_input(&body_json)?, key)
3406+
}
3407+
3408+
/// List through the same metadata-selected collection with an exact JSON query.
3409+
#[napi]
3410+
pub async fn list_proposals_json(
3411+
&self,
3412+
collection: &ProposalCollection,
3413+
query_json: String,
3414+
) -> Result<ProposalPage> {
3415+
self.list_proposals(collection, exact_input(&query_json)?)
3416+
.await
3417+
}
3418+
33963419
#[napi]
33973420
pub fn recover_proposal(
33983421
&self,

‎crates/registry-breg-client/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ client's configured credential unchanged. Each derived value owns its token, so
5656
different callers can use the same base client concurrently without a mutable
5757
credential slot. Keep it within the inbound request that supplied the token.
5858

59+
Use `client.with_maximum_response_bytes(limit)?` to derive a client with a
60+
separate response-body budget for a particular exchange. It reuses the same
61+
credential provider, connection pool and source binding, and leaves the original
62+
client's response limit unchanged. The limit must be greater than zero.
63+
5964
## Read records
6065

6166
```rust,no_run

‎crates/registry-breg-client/src/client.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ impl BaseRegistryClient {
7272
}
7373
}
7474

75+
/// Derive a client with a different finite response-body limit, reusing the
76+
/// same credential provider, connection pool, and source binding. The
77+
/// original client and all other transport policy remain unchanged.
78+
pub fn with_maximum_response_bytes(
79+
&self,
80+
maximum: u64,
81+
) -> Result<Self, BaseRegistryClientError> {
82+
let config = self.config.clone().with_maximum_response_bytes(maximum);
83+
config.validate()?;
84+
let mut transport = self.transport.clone();
85+
transport.maximum_response_bytes = maximum;
86+
Ok(Self { config, transport })
87+
}
88+
7589
/// Unauthenticated liveness probe. Configured bearer credentials are never
7690
/// acquired or sent.
7791
pub async fn health(&self) -> Result<BRegComplete<BRegProbeStatus>, BaseRegistryClientError> {

‎crates/registry-breg-client/src/metadata.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1796,7 +1796,10 @@ fn action_field_type(value: Value) -> Result<(), BRegMetadataError> {
17961796
}
17971797
"vocabulary-code" => {
17981798
bounded_string(required(&mut field_type, "vocabulary")?)?;
1799-
let values = identifier_array(required(&mut field_type, "values")?)?;
1799+
let values = array(required(&mut field_type, "values")?)?
1800+
.into_iter()
1801+
.map(bounded_short_text)
1802+
.collect::<Result<Vec<_>, _>>()?;
18001803
if values.is_empty() || ensure_unique(values.iter().map(String::as_str)).is_err() {
18011804
return Err(metadata_error(BRegMetadataErrorKind::Shape));
18021805
}

0 commit comments

Comments
 (0)