Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/registry-breg-client-node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,10 @@ binds `submissionVersion` and `effectDigest`; optional `reason` text is limited
to 4096 Unicode characters with NUL refused. Proposal conditions are distinct
from record ETags. Use `readProposal` or `getProposal` for a current observation.
Read review status or context explicitly through `readProposalReviewStatus`
or `readProposalReviewContext` when their links are promoted. A Proposal
or `readProposalReviewContext` when their links are promoted. Use
`readProposalReviewStatusWithProgress` to opt into the provider's optional
coarse `progressCode` while an accepted review request remains pending. The
plain Status and Context methods reject unsolicited progress. A Proposal
document carries a derived `turn` (`party`, `official` or `nobody`) and, once a
review has decided, a `decision` with its status, `decidedAt` and optional
`publicReason`. `readProposalSubmission(proposal, version)` reads one retained
Expand Down
4 changes: 4 additions & 0 deletions crates/registry-breg-client-node/__test__/facade.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ client.readProposalReviewStatus(proposal).then(outcome => {
const acceptedRequestId: string | undefined = outcome.value.requestId
acceptedRequestId?.toUpperCase()
})
client.readProposalReviewStatusWithProgress(proposal).then(outcome => {
const progressCode: string | undefined = outcome.value.progressCode
progressCode?.toUpperCase()
})
client.readProposalSubmission(proposal, 1).then(outcome => {
const status: 'approved' | 'rejected' | 'changes-requested' | 'answered' | undefined = outcome.value.decision?.status
const publicReason: string | undefined = outcome.value.decision?.publicReason
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,35 @@ test('Proposal response boundaries preserve disclosure and exact Action revision
value.result = {state: 'pending'};
await assert.rejects(client.readProposalReviewContext(prior), error => error.kind === 'invalid-request' && !error.message.includes('private-canary'));
});
await t.test('Review progress is an explicit Status projection', async () => {
value = document(); value.state = 'submitted'; delete value.application;
for (const [name, suffix, operation] of [['reviewStatus', 'review-status', 'get-review-status'], ['reviewContext', 'review-context', 'get-review-context']]) {
value.links[name] = {method: 'GET', path: `/v1/proposals/${id}/${suffix}`, accessProfile: 'reader', operation};
}
const prior = await client.getProposal(binding, id);
const progress = {id, actionId: 'correct', actionRevision: digest, submissionVersion: 2, revision: 8, effectDigest: digest,
subject: {source: 'directory', subjectType: 'proposal', id, version: '2', digest},
context: {reference: `breg:directory:correct:${id}:2`}, reviewable: true,
authority: 'review-provider', policy: {id: 'review', version: '1', digest}, requestId: id,
handoff: {state: 'accepted'}, result: {state: 'pending'}, progressCode: 'awaiting-assignment'};
value = structuredClone(progress);
await assert.rejects(client.readProposalReviewStatus(prior), error => error.kind === 'invalid-request');
const observed = (await client.readProposalReviewStatusWithProgress(prior)).value;
assert.equal(observed.progressCode, 'awaiting-assignment');
assert.equal(requests.at(-1), `/v1/proposals/${id}/review-status?accessProfile=reader&include=progress`);
await assert.rejects(client.readProposalReviewContext(prior), error => error.kind === 'invalid-request');

value = structuredClone(progress); delete value.progressCode;
assert.ok(!Object.hasOwn((await client.readProposalReviewStatusWithProgress(prior)).value, 'progressCode'));
const invalid = [['reviewable', false], ['handoff', {state: 'failed', reason: 'remote-refused'}],
['result', {state: 'approved', resultId: targetId, completedAt: '2026-10-01T00:00:00.000000Z', availableUntil: '2026-10-02T00:00:00.000000Z'}],
['progressCode', 'Invalid Progress']].map(([field, fieldValue]) => ({...structuredClone(progress), [field]: fieldValue}));
const missingRequest = structuredClone(progress); delete missingRequest.requestId; invalid.push(missingRequest);
for (const candidate of invalid) {
value = candidate;
await assert.rejects(client.readProposalReviewStatusWithProgress(prior), error => error.kind === 'invalid-request');
}
});
await t.test('non-Rebase transitions and attachment writes preserve the bound Action revision', async () => {
const successor = `sha256:${'b'.repeat(64)}`;
value = document(); value.state = 'draft'; delete value.application; delete value.effectDigest;
Expand Down
3 changes: 3 additions & 0 deletions crates/registry-breg-client-node/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,7 @@ export declare class BaseRegistryClient {
executeRecoveredProposalStart(binding: BRegActionBinding, recovered: BRegRecoveredAction): Promise<BRegProposal>
downloadProposalAttachment(proposal: BRegProposal, slot: string): Promise<RawOutcome>
readProposalReviewStatus(proposal: BRegProposal): Promise<CompleteOutcome<BRegProposalReviewDocument>>
readProposalReviewStatusWithProgress(proposal: BRegProposal): Promise<CompleteOutcome<BRegProposalReviewDocument>>
readProposalReviewContext(proposal: BRegProposal): Promise<CompleteOutcome<BRegProposalReviewDocument>>
readProposalReviewContextBatch(binding: BRegProposalReviewBinding, ids: string[]): Promise<CompleteOutcome<Array<BRegProposalReviewDocument | null>>>
readProposalReviewById(binding: BRegProposalReviewBinding, id: string): Promise<CompleteOutcome<BRegProposalReviewDocument>>
Expand Down Expand Up @@ -1038,6 +1039,8 @@ export interface BRegProposalReviewDocument {
readonly subject: {readonly source: string; readonly subjectType: 'proposal'; readonly id: string; readonly version: string; readonly digest: string}
readonly context: {readonly reference: string; readonly contract?: {readonly id: string; readonly revision: string; readonly schemaDigest: string}; readonly fields?: Readonly<Record<string, JsonValue>>}
readonly requestId?: string
/** Present only on an explicit progress status read while the accepted request remains pending. */
readonly progressCode?: string
/** Status reads only: where the submission stands with the Review provider. `reason` is present exactly when `state` is `failed`. */
readonly handoff?: {readonly state: 'pending' | 'accepted' | 'failed' | 'withdrawn'; readonly reason?: 'remote-refused' | 'producer-identity-mismatch' | 'review-acceptance-changed' | 'review-policy-changed' | 'submission-recovery-expired' | 'operator-closed' | 'unspecified'}
readonly authority?: string; readonly policy?: {readonly id: string; readonly version: string; readonly digest: string}
Expand Down
2 changes: 1 addition & 1 deletion crates/registry-breg-client-node/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ for (const [method, jsonIndexes, requiredIndexes] of [
['patchRecord', [3], [3]], ['batchRecords', [1], [1]], ['tombstoneRecord'],
['startProposal', [1], [1]], ['readProposal'], ['readProposalSubmission'], ['getProposal'], ['listProposals', [1], [1]], ['executeProposal'], ['listProposalsJson'],
['downloadProposalAttachment'], ['uploadProposalAttachment'], ['deleteProposalAttachment'],
['readProposalReviewStatus'], ['readProposalReviewContext'],
['readProposalReviewStatus'], ['readProposalReviewStatusWithProgress'], ['readProposalReviewContext'],
['readProposalReviewContextBatch', [1], [1]],
['readProposalReviewById'], ['downloadProposalReviewDocument', [1], [1]],
['getRecordJson', [2]], ['listRecordsJson', [1]], ['continueListJson', [0], [0]],
Expand Down
1 change: 1 addition & 0 deletions crates/registry-breg-client-node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export declare class BaseRegistryClient {
readProposalReviewById(binding: BRegProposalReviewBinding, id: string): Promise<CompleteOutcome>
downloadProposalReviewDocument(binding: BRegProposalReviewBinding, context: any, field: string): Promise<RawOutcome>
readProposalReviewStatus(proposal: BRegProposal): Promise<CompleteOutcome>
readProposalReviewStatusWithProgress(proposal: BRegProposal): Promise<CompleteOutcome>
readProposalReviewContext(proposal: BRegProposal): Promise<CompleteOutcome>
readProposalReviewContextBatch(binding: BRegProposalReviewBinding, ids: Array<string>): Promise<CompleteOutcome>
uploadProposalAttachment(proposal: BRegProposal, slot: string, contentType: string, bytes: Buffer, key: string): Promise<BRegProposal>
Expand Down
12 changes: 12 additions & 0 deletions crates/registry-breg-client-node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3295,6 +3295,18 @@ impl BaseRegistryClient {
complete_value(response.value, response.metadata)
}
#[napi]
pub async fn read_proposal_review_status_with_progress(
&self,
proposal: &Proposal,
) -> Result<CompleteOutcome> {
let response = self
.inner
.read_proposal_review_status_with_progress(&proposal.inner)
.await
.map_err(client_error)?;
complete_value(response.value, response.metadata)
}
#[napi]
pub async fn read_proposal_review_context(
&self,
proposal: &Proposal,
Expand Down
5 changes: 4 additions & 1 deletion crates/registry-breg-client-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,10 @@ binds `submissionVersion` and `effectDigest`; optional `reason` text is limited
to 4096 Unicode characters with NUL refused. Proposal conditions are distinct
from Record ETags. `proposal.document` exposes the validated wire document.
Read review status or context explicitly through `read_proposal_review_status`
or `read_proposal_review_context` when their links are promoted. A Proposal
or `read_proposal_review_context` when their links are promoted. Use
`read_proposal_review_status_with_progress` to opt into the provider's optional
coarse `progressCode` while an accepted review request remains pending. The
plain Status and Context methods reject unsolicited progress. A Proposal
document carries a derived `turn` (`party`, `official` or `nobody`) and, once a
review has decided, a `decision` with its status, `decidedAt` and optional
`publicReason`. `read_proposal_submission(proposal, version)` reads one
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,30 @@ class BRegProposalActionDescriptor(TypedDict):
readable_inputs: Sequence[str]
readable_context_fields: Sequence[str]

class BRegProposalReviewDocument(TypedDict):
id: str
actionId: str
actionRevision: str
submissionVersion: int
revision: int
effectDigest: str
subject: dict[str, JsonValue]
context: dict[str, JsonValue]
reviewable: bool
requestId: NotRequired[str]
progressCode: NotRequired[str]
handoff: NotRequired[dict[str, JsonValue]]
authority: NotRequired[str]
policy: NotRequired[dict[str, JsonValue]]
result: NotRequired[dict[str, JsonValue]]

class BRegProposalReviewCompleteOutcome(TypedDict):
kind: Literal["complete"]
value: BRegProposalReviewDocument
trace_id: str
etag: str | None
location: str | None

class BRegMetadata:
def event_record_descriptor(self, record_type: str) -> dict[str, JsonValue]:
"""The event record descriptor declared for a record type."""
Expand Down Expand Up @@ -638,6 +662,7 @@ class BaseRegistryClient:
def execute_recovered_proposal_start(self, binding: BRegActionBinding, recovered: BRegRecoveredAction) -> BRegProposal: ...
def download_proposal_attachment(self, proposal: BRegProposal, slot: str) -> dict[str, Any]: ...
def read_proposal_review_status(self, proposal: BRegProposal) -> dict[str, Any]: ...
def read_proposal_review_status_with_progress(self, proposal: BRegProposal) -> BRegProposalReviewCompleteOutcome: ...
def read_proposal_review_context(self, proposal: BRegProposal) -> dict[str, Any]: ...
def read_proposal_review_context_batch(self, binding: BRegProposalReviewBinding, ids: list[str]) -> dict[str, Any]: ...
def read_proposal_review_by_id(self, binding: BRegProposalReviewBinding, identifier: str) -> dict[str, Any]: ...
Expand Down
16 changes: 16 additions & 0 deletions crates/registry-breg-client-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2847,6 +2847,22 @@ impl BaseRegistryClient {
.map_err(|error| sdk_error(py, error))?;
complete_value(py, &response.value, &response.metadata)
}
fn read_proposal_review_status_with_progress<'py>(
&self,
py: Python<'py>,
proposal: PyRef<'_, Proposal>,
) -> PyResult<Bound<'py, PyAny>> {
let proposal = proposal.inner.clone();
let response = py
.detach(|| {
self.runtime.block_on(
self.inner
.read_proposal_review_status_with_progress(&proposal),
)
})
.map_err(|error| sdk_error(py, error))?;
complete_value(py, &response.value, &response.metadata)
}
fn read_proposal_review_context<'py>(
&self,
py: Python<'py>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,50 @@ def test_review_status_pending_terminal_expired_and_context_without_results(self
self.assertEqual(raised.exception.kind, 'invalid-request')
self.assertNotIn('private-canary', str(raised.exception))

def test_review_progress_is_an_explicit_status_projection(self):
self.value = document()
self.value['state'] = 'submitted'
del self.value['application']
for name, suffix, operation in [('reviewStatus', 'review-status', 'get-review-status'), ('reviewContext', 'review-context', 'get-review-context')]:
self.value['links'][name] = {'method': 'GET', 'path': '/v1/proposals/' + ID + '/' + suffix,
'accessProfile': 'reader', 'operation': operation}
prior = self.client.get_proposal(self.binding, ID)
progress = {'id': ID, 'actionId': 'correct', 'actionRevision': DIGEST, 'submissionVersion': 2, 'revision': 8,
'effectDigest': DIGEST, 'subject': {'source': 'directory', 'subjectType': 'proposal', 'id': ID,
'version': '2', 'digest': DIGEST},
'context': {'reference': 'breg:directory:correct:' + ID + ':2'}, 'reviewable': True,
'authority': 'review-provider', 'policy': {'id': 'review', 'version': '1', 'digest': DIGEST},
'requestId': ID, 'handoff': {'state': 'accepted'}, 'result': {'state': 'pending'},
'progressCode': 'awaiting-assignment'}
self.value = copy.deepcopy(progress)
with self.assertRaises(BaseRegistryClientError):
self.client.read_proposal_review_status(prior)
observed = self.client.read_proposal_review_status_with_progress(prior)['value']
self.assertEqual(observed['progressCode'], 'awaiting-assignment')
self.assertEqual(self.requests[-1][0], '/tenant/v1/proposals/' + ID + '/review-status?accessProfile=reader&include=progress')
with self.assertRaises(BaseRegistryClientError):
self.client.read_proposal_review_context(prior)

self.value = copy.deepcopy(progress)
del self.value['progressCode']
self.assertNotIn('progressCode', self.client.read_proposal_review_status_with_progress(prior)['value'])
invalid = []
for field, value in [('reviewable', False), ('handoff', {'state': 'failed', 'reason': 'remote-refused'}),
('result', {'state': 'approved', 'resultId': TARGET,
'completedAt': '2026-10-01T00:00:00.000000Z',
'availableUntil': '2026-10-02T00:00:00.000000Z'}),
('progressCode', 'Invalid Progress')]:
candidate = copy.deepcopy(progress)
candidate[field] = value
invalid.append(candidate)
missing_request = copy.deepcopy(progress)
del missing_request['requestId']
invalid.append(missing_request)
for candidate in invalid:
self.value = candidate
with self.subTest(candidate=candidate), self.assertRaises(BaseRegistryClientError):
self.client.read_proposal_review_status_with_progress(prior)

def test_non_rebase_transitions_and_attachment_writes_preserve_action_revision(self):
successor = 'sha256:' + 'b' * 64
self.value = document()
Expand Down
7 changes: 6 additions & 1 deletion crates/registry-breg-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,12 @@ The built-in executor uses its own governed system grant.
`select_proposals` and `list_proposals` read one bounded page; advance its
cursor explicitly. `read_proposal_review_status` and
`read_proposal_review_context` use the Proposal's separately promoted review
links. `upload_proposal_attachment`, `download_proposal_attachment`, and
links. `read_proposal_review_status_with_progress` appends the fixed
`include=progress` query to the verified status link to request the provider's
optional coarse `progressCode`. Plain status and
context reads reject that field. A progress code is accepted only while the
reviewable request has an accepted handoff, a request ID, and a pending current
result. `upload_proposal_attachment`, `download_proposal_attachment`, and
`delete_proposal_attachment` use its governed slot links, not record fields.
Mutation receipts retain the selected slot. Disclosed upload metadata must match
those exact bytes, media type and length; deletion must not retain a disclosed
Expand Down
26 changes: 26 additions & 0 deletions crates/registry-breg-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,32 @@ impl BaseRegistryClient {
}
}

pub(crate) async fn proposal_review_progress_exchange(
&self,
link: &BRegProposalLink,
) -> Result<BRegComplete<BRegRawDocument>, BaseRegistryClientError> {
if link.method() != "GET" || link.operation() != "get-review-status" {
return Err(BaseRegistryClientError::invalid_request(
"the selected Base Registry Engine Proposal review status link is invalid",
));
}
let segments = fixed_operation_segments(link.path())?;
let mut pairs = access_profile_query(Some(link.access_profile()))?;
pairs.push(("include".to_owned(), "progress".to_owned()));
let url = self.url_with_query(&segments, &pairs)?;
let builder = self
.authorize(
self.transport
.http
.get(url)
.header(ACCEPT, APPLICATION_JSON),
Credential::Optional,
)
.await?;
self.decode_proposal_response(self.transport.send(builder).await?)
.await
}

fn proposal_request(
&self,
link: &BRegProposalLink,
Expand Down
2 changes: 1 addition & 1 deletion crates/registry-breg-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ pub use registry_record::{
REGISTRY_RECORD_CONTEXT_IDENTIFIER, REGISTRY_RECORD_PROFILE_IDENTIFIER,
REGISTRY_RECORD_SCHEMA_IDENTIFIER,
};
pub use registry_review_protocol::PolicySelection;
pub use registry_review_protocol::{PolicySelection, ReviewProgressCode};
pub use response::*;
pub use statistics::*;
pub use uuid::Uuid;
Expand Down
Loading
Loading