Skip to content

Commit af56e2d

Browse files
committed
fix(breg): bind occurrence creation evidence to record type
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
1 parent 68fae65 commit af56e2d

15 files changed

Lines changed: 156 additions & 40 deletions

File tree

‎crates/CLIENTS.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,12 @@ or unify distinct authority contracts. Shared HTTP/token primitives live in
7474
Recorded time comes only from the creation revision of that record, which
7575
`occurrence_creation_revision` reads as exactly revision 1 and reports as
7676
absent only for a 403 or 404 answer, which means the caller cannot see that
77-
revision; every other status is returned as an error. Evidence that does not match the
78-
record's exact type, identifier, and revision is listed as ignored, never
79-
merged. Instants are emitted with an uppercase `T` and `Z` and the offset the
77+
revision; every other status is returned as an error. Its compact
78+
`{recordType, recordId, revision, createdAt}` form retains the response's
79+
record type so a same-UUID revision of another type cannot supply recorded
80+
time. Evidence that does not match the record's exact type, identifier, and
81+
revision is listed as ignored, never merged. Instants are emitted with an
82+
uppercase `T` and `Z` and the offset the
8083
registry served (it serves stored timestamps in UTC with `+00:00`); dates stay
8184
dates. The Rust projection is the single implementation:
8285
the Node and Python exports (`occurrenceView`, `occurrence_view`, and the

‎crates/registry-breg-client-node/__test__/occurrence-view.test.js‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,8 @@ test('the metadata accessor and the creation-revision read follow the declared c
118118

119119
const text = await client.occurrenceCreationRevision('incorporations', recordId);
120120
assert.deepEqual(JSON.parse(text), {
121-
recordId, revision: '1', createdAt: '2026-10-26T08:15:30.123456Z',
121+
recordType: 'incorporation', recordId, revision: '1',
122+
createdAt: '2026-10-26T08:15:30.123456Z',
122123
});
123124
assert.ok(requests.some((url) => url.includes(`/incorporations/${recordId}/revisions/1`)));
124125
assert.equal(await client.occurrenceCreationRevision('incorporations', hiddenId), null);
@@ -127,6 +128,13 @@ test('the metadata accessor and the creation-revision read follow the declared c
127128
JSON.parse(occurrenceView(JSON.stringify(input))).recordedAt,
128129
{ status: 'available', type: 'instant', value: '2026-10-26T08:15:30.123456Z' },
129130
);
131+
input.creationRevision.recordType = 'ownership-transfer';
132+
assert.throws(() => occurrenceView(JSON.stringify(input)), (error) => {
133+
assert.equal(error.kind, 'occurrence');
134+
assert.equal(error.code, 'wrong-record-evidence');
135+
assert.doesNotMatch(error.message, /ownership-transfer/);
136+
return true;
137+
});
130138
} finally {
131139
await new Promise((resolve) => server.close(resolve));
132140
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -896,8 +896,9 @@ export declare class BaseRegistryClient {
896896
openapi(accessProfile?: string | null): Promise<RawOutcome>
897897
registryMetadata(accessProfile?: string | null): Promise<RawOutcome>
898898
/**
899-
* The first revision of a record as `{recordId, revision, createdAt}` JSON
900-
* text, or null when the caller may not read it (403 or 404).
899+
* The first revision of a record as
900+
* `{recordType, recordId, revision, createdAt}` JSON text, or null when the
901+
* caller may not read it (403 or 404).
901902
*/
902903
occurrenceCreationRevision(entityRoute: string, recordIdentifier: string, accessProfile?: string | null): Promise<string | null>
903904
statisticsLive(dataset: string, from?: string | null, to?: string | null, accessProfile?: string | null, format?: StatisticsFormat | null): Promise<RawOutcome>

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ export declare class BaseRegistryClient {
2929
/** Retrieve one exact retained revision as validated inert record JSON. */
3030
getRecordRevision(entityRoute: string, recordIdentifier: string, revision: number, options?: any | undefined | null): Promise<RawOutcome>
3131
/**
32-
* Read the first revision of one record as the compact creation-revision
33-
* JSON, or null when the caller may not read it.
32+
* Read the first revision of one record as the compact
33+
* `{recordType, recordId, revision, createdAt}` JSON, or null when the
34+
* caller may not read it.
3435
*/
3536
occurrenceCreationRevision(entityRoute: string, recordIdentifier: string, accessProfile?: string | undefined | null): Promise<string | null>
3637
getRecord(entityRoute: string, recordIdentifier: string, options?: any | undefined | null): Promise<CompleteOutcome>

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2924,8 +2924,9 @@ impl BaseRegistryClient {
29242924
.map_err(client_error)
29252925
}
29262926

2927-
/// Read the first revision of one record as the compact creation-revision
2928-
/// JSON, or null when the caller may not read it.
2927+
/// Read the first revision of one record as the compact
2928+
/// `{recordType, recordId, revision, createdAt}` JSON, or null when the
2929+
/// caller may not read it.
29292930
#[napi]
29302931
pub async fn occurrence_creation_revision(
29312932
&self,

‎crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,8 +337,8 @@ class BaseRegistryClient:
337337
*,
338338
access_profile: str | None = None,
339339
) -> dict[str, str] | None:
340-
"""The first revision of a record as `recordId`, `revision` and `createdAt`,
341-
or None when the caller may not read it (403 or 404)."""
340+
"""The first revision of a record as `recordType`, `recordId`, `revision`
341+
and `createdAt`, or None when the caller may not read it (403 or 404)."""
342342
...
343343
def statistics_live(
344344
self, dataset: str, *, from_period: str | None = None,

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1820,8 +1820,9 @@ impl BaseRegistryClient {
18201820
raw_value(py, &value.value, &value.metadata)
18211821
}
18221822

1823-
/// The first revision of one record as `{recordId, revision, createdAt}`,
1824-
/// or `None` when the caller may not read it.
1823+
/// The first revision of one record as
1824+
/// `{recordType, recordId, revision, createdAt}`, or `None` when the caller
1825+
/// may not read it.
18251826
#[pyo3(signature = (entity_route, record_identifier, *, access_profile=None))]
18261827
fn occurrence_creation_revision<'py>(
18271828
&self,

‎crates/registry-breg-client-py/tests/python/test_occurrence_view.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,12 @@ def log_message(self, *_args: object) -> None:
147147
revision = client.occurrence_creation_revision("incorporations", RECORD_ID)
148148
self.assertEqual(
149149
revision,
150-
{"recordId": RECORD_ID, "revision": "1", "createdAt": CREATED_AT},
150+
{
151+
"recordType": "incorporation",
152+
"recordId": RECORD_ID,
153+
"revision": "1",
154+
"createdAt": CREATED_AT,
155+
},
151156
)
152157
self.assertIsNone(
153158
client.occurrence_creation_revision("incorporations", HIDDEN_ID)
@@ -158,6 +163,12 @@ def log_message(self, *_args: object) -> None:
158163
occurrence_view(arguments)["recordedAt"],
159164
{"status": "available", "type": "instant", "value": CREATED_AT},
160165
)
166+
arguments["creationRevision"]["recordType"] = "ownership-transfer"
167+
with self.assertRaises(BaseRegistryClientError) as caught:
168+
occurrence_view(arguments)
169+
self.assertEqual(caught.exception.kind, "occurrence")
170+
self.assertEqual(caught.exception.code, "wrong-record-evidence")
171+
self.assertNotIn("ownership-transfer", str(caught.exception))
161172
finally:
162173
server.shutdown()
163174
thread.join()

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,8 @@ impl BaseRegistryClient {
767767
/// read that revision (a 403 or 404 problem answer); the absence is never
768768
/// replaced by another timestamp. Every other failure is returned, and a
769769
/// revision 1 document that is not the record's creation is a body failure.
770+
/// The retained evidence binds the response's record type, record identifier,
771+
/// revision and creation time.
770772
pub async fn occurrence_creation_revision(
771773
&self,
772774
entity_route: &str,

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

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,7 @@ fn time_value(time: &TimeDescriptor) -> Value {
512512
/// states when the registry first recorded it.
513513
#[derive(Clone, Debug, Eq, PartialEq)]
514514
pub struct BRegCreationRevision {
515+
record_type: String,
515516
record_id: String,
516517
created_at: String,
517518
}
@@ -520,6 +521,12 @@ impl BRegCreationRevision {
520521
/// Read a Registry Record revision envelope (`{"data": {...}, "meta": {...}}`)
521522
/// and keep only the creation evidence for `record_id`.
522523
pub fn from_document(record_id: &str, document: &Value) -> Result<Self, BRegOccurrenceError> {
524+
let record_type = document
525+
.get("meta")
526+
.and_then(Value::as_object)
527+
.and_then(|meta| meta.get("entityTypeIdentifier"))
528+
.and_then(Value::as_str)
529+
.ok_or_else(|| wrong_evidence("the revision has no record type"))?;
523530
let data = document
524531
.get("data")
525532
.and_then(Value::as_object)
@@ -545,19 +552,21 @@ impl BRegCreationRevision {
545552
.get("createdAt")
546553
.and_then(Value::as_str)
547554
.ok_or_else(|| wrong_evidence("the revision has no creation time"))?;
548-
Self::new(record_id, created_at)
555+
Self::new(record_type, record_id, created_at)
549556
}
550557

551-
/// Read the compact `{"recordId", "revision", "createdAt"}` form that
552-
/// `Serialize` writes.
558+
/// Read the compact `{"recordType", "recordId", "revision", "createdAt"}`
559+
/// form that `Serialize` writes.
553560
pub fn from_value(value: &Value) -> Result<Self, BRegOccurrenceError> {
554561
let object = value
555562
.as_object()
556563
.ok_or_else(|| wrong_evidence("the creation revision is not an object"))?;
557-
if object
558-
.keys()
559-
.any(|key| !matches!(key.as_str(), "recordId" | "revision" | "createdAt"))
560-
{
564+
if object.keys().any(|key| {
565+
!matches!(
566+
key.as_str(),
567+
"recordType" | "recordId" | "revision" | "createdAt"
568+
)
569+
}) {
561570
return Err(wrong_evidence(
562571
"the creation revision has an unknown member",
563572
));
@@ -569,21 +578,35 @@ impl BRegCreationRevision {
569578
.ok_or_else(|| wrong_evidence(format!("the creation revision has no {name}")))
570579
};
571580
require_creation_revision(member("revision")?)?;
572-
Self::new(member("recordId")?, member("createdAt")?)
581+
Self::new(
582+
member("recordType")?,
583+
member("recordId")?,
584+
member("createdAt")?,
585+
)
573586
}
574587

575-
fn new(record_id: &str, created_at: &str) -> Result<Self, BRegOccurrenceError> {
588+
fn new(
589+
record_type: &str,
590+
record_id: &str,
591+
created_at: &str,
592+
) -> Result<Self, BRegOccurrenceError> {
576593
if OffsetDateTime::parse(created_at, &Rfc3339).is_err() {
577594
return Err(wrong_evidence(
578595
"the creation time is not an RFC 3339 timestamp",
579596
));
580597
}
581598
Ok(Self {
599+
record_type: record_type.to_owned(),
582600
record_id: record_id.to_owned(),
583601
created_at: created_at.to_owned(),
584602
})
585603
}
586604

605+
#[must_use]
606+
pub fn record_type_identifier(&self) -> &str {
607+
&self.record_type
608+
}
609+
587610
#[must_use]
588611
pub fn record_identifier(&self) -> &str {
589612
&self.record_id
@@ -620,6 +643,7 @@ fn require_creation_revision(revision: &str) -> Result<(), BRegOccurrenceError>
620643
impl Serialize for BRegCreationRevision {
621644
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
622645
serde_json::json!({
646+
"recordType": self.record_type,
623647
"recordId": self.record_id,
624648
"revision": CREATION_REVISION,
625649
"createdAt": self.created_at,
@@ -961,7 +985,10 @@ fn build_view(
961985
));
962986
}
963987
let recorded_at = match creation_revision {
964-
Some(creation) if creation.record_id != record.record_id => {
988+
Some(creation)
989+
if creation.record_type != record.record_type
990+
|| creation.record_id != record.record_id =>
991+
{
965992
return Err(wrong_evidence(
966993
"the creation revision belongs to another record",
967994
));

0 commit comments

Comments
 (0)