P2.3: publish enforces cross-entity identity (no cross-app dangling edge)

Repo.publish validated entities individually and checked
artifact.release_id == release.id, but never verified the published
aggregate forms one consistent identity graph. It could commit a channel
whose app_id differs from the release's app (a channel of app B pointing
at app A's release) or artifacts whose app_id differs from the release's
app — exactly the dangling/cross-app edge the acceptance forbids.

Add Integrity preconditions to the publish transaction (reusing the
existing len-reset/channel-restore rollback so the model is unchanged on
failure): the release's app must exist, the promoted channel must belong
to that app (chan.app_id == release.app_id), and every artifact must
belong to that app AND name this release (a.app_id == release.app_id and
a.release_id == release.id).

Extend tests/repo_transaction.sx with cross-app channel and cross-app
artifact cases asserting publish raises Integrity and leaves the model
unchanged; the existing rollback and no-dangling assertions stay green.
This commit is contained in:
agra
2026-06-06 01:19:22 +03:00
parent aa3b690381
commit d8380ed451
2 changed files with 84 additions and 6 deletions

View File

@@ -24,8 +24,10 @@
#import "../domain/validate.sx";
// Failure classes for the publish transaction. `Validation` = a release /
// artifact / channel failed domain validation; `Integrity` = an artifact
// named a release_id other than the release being published.
// artifact / channel failed domain validation; `Integrity` = the published
// aggregate is cross-identity inconsistent — the release names an app that
// doesn't exist, or an artifact / promoted channel whose app_id or release_id
// doesn't match the release being published.
PublishErr :: error {
Validation,
Integrity,
@@ -191,6 +193,13 @@ Repo :: struct {
// and, in particular, no channel left pointing at a release that isn't
// in the repo (the "no dangling release" invariant).
//
// The published aggregate must also form ONE consistent identity graph,
// else committing it would create a cross-app dangling edge. So, as an
// Integrity precondition: the release's app must exist, the promoted
// channel must belong to that app (chan.app_id == release.app_id), and
// every artifact must belong to that app AND name this release
// (a.app_id == release.app_id and a.release_id == release.id).
//
// Rollback is by snapshot: List appends only bump `len`, so undoing them
// is a `len` reset; an updated existing channel is restored from a saved
// copy. The channel pointer is forced to `release.id` here, so a
@@ -207,12 +216,25 @@ Repo :: struct {
integrity := false;
validate_release(release) catch { failed = true; };
// Cross-entity identity preconditions for the whole aggregate: the
// release's app must exist and the promoted channel must belong to
// that same app. (Per-artifact identity is checked in the loop.)
if !failed {
if self.get_app(release.app_id) == null { integrity = true; failed = true; }
}
if !failed {
if chan.app_id != release.app_id { integrity = true; failed = true; }
}
if !failed {
self.releases.append(release, self.own_allocator);
i := 0;
while i < arts.len {
a := arts.items[i];
if a.release_id != release.id { integrity = true; failed = true; break; }
if a.app_id != release.app_id or a.release_id != release.id {
integrity = true; failed = true; break;
}
validate_artifact(a) catch { failed = true; };
if failed { break; }
self.artifacts.append(a, self.own_allocator);