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:
@@ -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);
|
||||
|
||||
@@ -36,16 +36,22 @@ mk_release :: (id: string, version: string) -> Release {
|
||||
};
|
||||
}
|
||||
mk_artifact :: (id: string, release_id: string, digest: string) -> Artifact {
|
||||
return mk_artifact_for(id, "app_01", release_id, digest);
|
||||
}
|
||||
mk_artifact_for :: (id: string, app_id: string, release_id: string, digest: string) -> Artifact {
|
||||
return Artifact.{
|
||||
id = id, app_id = "app_01", release_id = release_id, platform = .android_apk,
|
||||
id = id, app_id = app_id, release_id = release_id, platform = .android_apk,
|
||||
filename = "a.apk", content_type = "application/vnd.android.package-archive",
|
||||
size_bytes = 1024, sha256 = digest, storage_key = digest,
|
||||
metadata = "", validation_status = .pending,
|
||||
};
|
||||
}
|
||||
the_channel :: () -> Channel {
|
||||
return mk_channel_for("app_01");
|
||||
}
|
||||
mk_channel_for :: (app_id: string) -> Channel {
|
||||
return Channel.{
|
||||
app_id = "app_01", name = "stable", current_release_id = "",
|
||||
app_id = app_id, name = "stable", current_release_id = "",
|
||||
policy = .manual, rollout_percent = 100,
|
||||
};
|
||||
}
|
||||
@@ -124,7 +130,57 @@ main :: () -> s32 {
|
||||
process.assert(c2v.current_release_id == "rel_00", "integrity rollback: channel still rel_00");
|
||||
print(" integrity failure rolled back: channel still rel_00\n");
|
||||
|
||||
// ── 4. Persisted state reflects the rollback, not the attempts ───
|
||||
// ── 4. Cross-app channel promotion: chan.app_id != release.app_id ─
|
||||
// The release belongs to app_01 but the promoted channel claims app_02.
|
||||
// Committing it would leave a channel of one app pointing at another
|
||||
// app's release — a cross-identity dangling edge. Must raise Integrity.
|
||||
arts_xc : List(Artifact) = .{};
|
||||
arts_xc.append(mk_artifact("art_xc", "rel_xc", DIGEST_B)); // self-consistent artifact
|
||||
xc_failed := false;
|
||||
xc_integrity := false;
|
||||
repo.publish(mk_release("rel_xc", "1.3.0"), @arts_xc, mk_channel_for("app_02")) catch e {
|
||||
xc_failed = true;
|
||||
xc_integrity = (e == error.Integrity);
|
||||
};
|
||||
process.assert(xc_failed, "cross-app channel promotion must fail");
|
||||
process.assert(xc_integrity, "cross-app channel promotion must raise Integrity");
|
||||
process.assert(repo.releases.len == rel_n, "cross-app channel rollback: release count unchanged");
|
||||
process.assert(repo.artifacts.len == art_n, "cross-app channel rollback: artifact count unchanged");
|
||||
process.assert(repo.channels.len == chan_n, "cross-app channel rollback: channel count unchanged");
|
||||
process.assert(repo.get_release("rel_xc") == null, "cross-app channel rollback: release absent");
|
||||
process.assert(repo.get_channel("app_02", "stable") == null, "cross-app channel rollback: no app_02 channel created");
|
||||
cx := repo.get_channel("app_01", "stable");
|
||||
process.assert(cx != null, "cross-app channel rollback: app_01 channel still exists");
|
||||
cxv := cx!;
|
||||
process.assert(cxv.current_release_id == "rel_00", "cross-app channel rollback: app_01 channel still -> rel_00");
|
||||
print(" cross-app channel promotion rejected: model unchanged\n");
|
||||
|
||||
// ── 5. Cross-app artifact: artifact.app_id != release.app_id ─────
|
||||
// The release and channel both belong to app_01, but the artifact claims
|
||||
// app_02 (its release_id still matches). Only release_id was checked
|
||||
// before; the app_id mismatch must now raise Integrity and roll back.
|
||||
arts_xa : List(Artifact) = .{};
|
||||
arts_xa.append(mk_artifact_for("art_xa", "app_02", "rel_xa", DIGEST_B));
|
||||
xa_failed := false;
|
||||
xa_integrity := false;
|
||||
repo.publish(mk_release("rel_xa", "1.4.0"), @arts_xa, the_channel()) catch e {
|
||||
xa_failed = true;
|
||||
xa_integrity = (e == error.Integrity);
|
||||
};
|
||||
process.assert(xa_failed, "cross-app artifact must fail");
|
||||
process.assert(xa_integrity, "cross-app artifact must raise Integrity");
|
||||
process.assert(repo.releases.len == rel_n, "cross-app artifact rollback: release count unchanged");
|
||||
process.assert(repo.artifacts.len == art_n, "cross-app artifact rollback: artifact count unchanged");
|
||||
process.assert(repo.channels.len == chan_n, "cross-app artifact rollback: channel count unchanged");
|
||||
process.assert(repo.get_release("rel_xa") == null, "cross-app artifact rollback: release absent");
|
||||
process.assert(repo.find_artifact_by_digest(DIGEST_B) == null, "cross-app artifact rollback: artifact absent");
|
||||
cy := repo.get_channel("app_01", "stable");
|
||||
process.assert(cy != null, "cross-app artifact rollback: channel still exists");
|
||||
cyv := cy!;
|
||||
process.assert(cyv.current_release_id == "rel_00", "cross-app artifact rollback: channel still -> rel_00");
|
||||
print(" cross-app artifact rejected: model unchanged\n");
|
||||
|
||||
// ── 6. Persisted state reflects the rollback, not the attempts ───
|
||||
serr := false;
|
||||
save(repo, root) catch { serr = true; };
|
||||
process.assert(!serr, "save must succeed");
|
||||
|
||||
Reference in New Issue
Block a user