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:
agra
2026-06-06 01:27:54 +03:00
parent d8380ed451
commit c541fac7ce
2 changed files with 89 additions and 18 deletions

View File

@@ -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);

View File

@@ -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");