P2.3: publish enforces channel-name target + release-id uniqueness
Close the remaining publish aggregate-consistency edges (review round 2, F1 continued): - chan.name == release.channel — the promoted channel must be the one the release declares as its target; promoting a "beta" channel for a release whose channel is "stable" committed an edge contradicting the release's own target. Now rejected with Integrity + rollback. - release.id must be new — a colliding id would shadow the existing release (get_release resolves to the OLD one), so the channel edge would silently point at a different release than the one published. Now rejected with Integrity + rollback. tests/repo_transaction.sx: add a channel-name-mismatch case and a release-id-collision case (both assert Integrity + model unchanged); existing fully-consistent publish still commits. Both new cases fail on the pre-fix repo.sx and pass after.
This commit is contained in:
@@ -26,8 +26,9 @@
|
||||
// Failure classes for the publish transaction. `Validation` = a release /
|
||||
// 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.
|
||||
// doesn't exist, a release id that already exists, an artifact whose app_id /
|
||||
// release_id doesn't match the release, or a promoted channel that belongs to
|
||||
// a different app or is not the channel the release targets (name mismatch).
|
||||
PublishErr :: error {
|
||||
Validation,
|
||||
Integrity,
|
||||
@@ -194,11 +195,15 @@ Repo :: struct {
|
||||
// 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).
|
||||
// else committing it would create a dangling or ambiguous edge. So, as
|
||||
// Integrity preconditions: the release's app must exist; the release id
|
||||
// must be new (a colliding id would shadow the existing release, leaving
|
||||
// the channel edge pointing at a different release than the one
|
||||
// published); the promoted channel must belong to that app
|
||||
// (chan.app_id == release.app_id) AND be the channel this release targets
|
||||
// (chan.name == release.channel); 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
|
||||
@@ -218,14 +223,23 @@ Repo :: struct {
|
||||
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.)
|
||||
// release's app must exist, the release id must be new (else the
|
||||
// channel edge would resolve to a pre-existing release, not this one),
|
||||
// and the promoted channel must belong to that same app AND be the
|
||||
// channel this release targets (chan.name == release.channel).
|
||||
// (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 self.get_release(release.id) != null { integrity = true; failed = true; }
|
||||
}
|
||||
if !failed {
|
||||
if chan.app_id != release.app_id { integrity = true; failed = true; }
|
||||
}
|
||||
if !failed {
|
||||
if chan.name != release.channel { integrity = true; failed = true; }
|
||||
}
|
||||
|
||||
if !failed {
|
||||
self.releases.append(release, self.own_allocator);
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
// Flow:
|
||||
// 1. Seed via a SUCCESSFUL publish (rel_00 + art_00, channel stable ->
|
||||
// rel_00) — exercises the commit path.
|
||||
// 2. A publish whose 2nd artifact is invalid must RAISE and roll back:
|
||||
// counts unchanged, channel still -> rel_00, rel_01 absent, the (valid)
|
||||
// first artifact absent too.
|
||||
// 3. A publish whose artifact names the wrong release_id must RAISE
|
||||
// Integrity and roll back identically.
|
||||
// 4. Persist + reload: the rolled-back state is what hits db.json — the
|
||||
// reloaded channel still points at rel_00 and rel_01 is absent.
|
||||
// 2. A publish whose 2nd artifact is invalid must RAISE Validation and roll
|
||||
// back: counts unchanged, channel still -> rel_00, nothing inserted.
|
||||
// 3..7. Each Integrity precondition rejects + rolls back identically:
|
||||
// (3) artifact.release_id mismatch, (4) cross-app channel
|
||||
// (chan.app_id != release.app_id), (5) cross-app artifact
|
||||
// (artifact.app_id != release.app_id), (6) channel-NAME mismatch
|
||||
// (chan.name != release.channel), (7) release-id collision
|
||||
// (release.id already exists).
|
||||
// 8. Persist + reload: the rolled-back state is what hits db.json — the
|
||||
// reloaded channel still points at rel_00 and the attempted releases
|
||||
// are absent.
|
||||
// Uses a fresh `<root>` under `.sx-tmp/` and cleans up.
|
||||
#import "modules/std.sx";
|
||||
process :: #import "modules/process.sx";
|
||||
@@ -50,8 +54,11 @@ the_channel :: () -> Channel {
|
||||
return mk_channel_for("app_01");
|
||||
}
|
||||
mk_channel_for :: (app_id: string) -> Channel {
|
||||
return mk_named_channel(app_id, "stable");
|
||||
}
|
||||
mk_named_channel :: (app_id: string, name: string) -> Channel {
|
||||
return Channel.{
|
||||
app_id = app_id, name = "stable", current_release_id = "",
|
||||
app_id = app_id, name = name, current_release_id = "",
|
||||
policy = .manual, rollout_percent = 100,
|
||||
};
|
||||
}
|
||||
@@ -180,7 +187,57 @@ main :: () -> s32 {
|
||||
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 ───
|
||||
// ── 6. Channel-NAME mismatch: chan.name != release.channel ───────
|
||||
// The release declares channel = "stable" as its target, but a channel
|
||||
// NAMED "beta" (same app) is promoted. Committing would point the beta
|
||||
// channel at a release that names stable as its target — an edge that
|
||||
// contradicts the release's own declared channel. Must raise Integrity.
|
||||
arts_cn : List(Artifact) = .{};
|
||||
arts_cn.append(mk_artifact("art_cn", "rel_cn", DIGEST_B)); // self-consistent artifact
|
||||
cn_failed := false;
|
||||
cn_integrity := false;
|
||||
repo.publish(mk_release("rel_cn", "1.5.0"), @arts_cn, mk_named_channel("app_01", "beta")) catch e {
|
||||
cn_failed = true;
|
||||
cn_integrity = (e == error.Integrity);
|
||||
};
|
||||
process.assert(cn_failed, "channel-name mismatch must fail");
|
||||
process.assert(cn_integrity, "channel-name mismatch must raise Integrity");
|
||||
process.assert(repo.releases.len == rel_n, "channel-name rollback: release count unchanged");
|
||||
process.assert(repo.artifacts.len == art_n, "channel-name rollback: artifact count unchanged");
|
||||
process.assert(repo.channels.len == chan_n, "channel-name rollback: channel count unchanged");
|
||||
process.assert(repo.get_release("rel_cn") == null, "channel-name rollback: release absent");
|
||||
process.assert(repo.get_channel("app_01", "beta") == null, "channel-name rollback: no beta channel created");
|
||||
ccn := repo.get_channel("app_01", "stable");
|
||||
process.assert(ccn != null, "channel-name rollback: stable channel still exists");
|
||||
ccnv := ccn!;
|
||||
process.assert(ccnv.current_release_id == "rel_00", "channel-name rollback: stable channel still -> rel_00");
|
||||
print(" channel-name mismatch rejected: model unchanged\n");
|
||||
|
||||
// ── 7. Release-id collision: a publish must introduce a NEW release ─
|
||||
// Re-publishing rel_00's id would shadow the existing release: get_release
|
||||
// resolves to the OLD one, so the channel edge would silently point at a
|
||||
// release other than the one being published. Must raise Integrity and
|
||||
// leave the original rel_00 untouched.
|
||||
arts_dup : List(Artifact) = .{};
|
||||
arts_dup.append(mk_artifact("art_dup", "rel_00", DIGEST_B));
|
||||
dup_failed := false;
|
||||
dup_integrity := false;
|
||||
repo.publish(mk_release("rel_00", "9.9.9"), @arts_dup, the_channel()) catch e {
|
||||
dup_failed = true;
|
||||
dup_integrity = (e == error.Integrity);
|
||||
};
|
||||
process.assert(dup_failed, "republishing an existing release id must fail");
|
||||
process.assert(dup_integrity, "release-id collision must raise Integrity");
|
||||
process.assert(repo.releases.len == rel_n, "collision rollback: release count unchanged");
|
||||
process.assert(repo.artifacts.len == art_n, "collision rollback: artifact count unchanged");
|
||||
process.assert(repo.channels.len == chan_n, "collision rollback: channel count unchanged");
|
||||
rdup := repo.get_release("rel_00");
|
||||
process.assert(rdup != null, "collision: original rel_00 still present");
|
||||
rdupv := rdup!;
|
||||
process.assert(rdupv.version == "1.0.0", "collision: original release not overwritten");
|
||||
print(" release-id collision rejected: original release untouched\n");
|
||||
|
||||
// ── 8. 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