From 8cff86b7e9d73068bbae1dfa99756794ade77b7c Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:21:00 -0700 Subject: [PATCH 1/4] feat(core): let the first assertion of an unclaimed cid take the claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content auth rejected a track naming a cid nobody holds. Every cid that can belong to someone else is already claimed — migrated tracks are seeded by the replay, uploads to an enforcing node are attested before the upload reads done — so what an unclaimed cid can be is audio that reached storage without an attestation: uploads made before the node ran the gate, and multipart uploads that named no user. Refusing those strands every such track, and stalls the API's new-chain flusher on the first post-snapshot track create, since it never advances past a refused row. Now the write passes and the projection records the writer as claimant; from then on the cid is theirs alone. A cid someone already holds is still refused at validation and never reassigned at finalize, so a block from a proposer without the gate cannot transfer one. Claims from live writes are recorded only under ContentAuthEnforced, so a chain accumulates nothing from live traffic before its gate. Co-Authored-By: Claude Fable 5.1 --- pkg/core/server/abci.go | 2 +- pkg/core/server/auth_enforcement.go | 1 + pkg/core/server/auth_state.go | 23 ++++- pkg/core/server/content_auth_state.go | 48 ++++++++- pkg/core/server/content_auth_state_test.go | 98 +++++++++++++++++-- pkg/core/server/manage_entity.go | 6 +- pkg/integration_tests/14_content_auth_test.go | 16 ++- 7 files changed, 174 insertions(+), 20 deletions(-) diff --git a/pkg/core/server/abci.go b/pkg/core/server/abci.go index ec25c616..2c0bf5e1 100644 --- a/pkg/core/server/abci.go +++ b/pkg/core/server/abci.go @@ -1332,7 +1332,7 @@ func (s *Server) finalizeTransaction(ctx context.Context, req *abcitypes.Finaliz case *v1.SignedTransaction_Plays: return s.finalizePlayTransaction(ctx, msg) case *v1.SignedTransaction_ManageEntity: - return s.finalizeManageEntity(ctx, msg) + return s.finalizeManageEntity(ctx, msg, blockHeight) case *v1.SignedTransaction_ManageEntityMigration: return s.finalizeManageEntityMigration(ctx, msg) case *v1.SignedTransaction_Attestation: diff --git a/pkg/core/server/auth_enforcement.go b/pkg/core/server/auth_enforcement.go index 678ee848..8c7f3284 100644 --- a/pkg/core/server/auth_enforcement.go +++ b/pkg/core/server/auth_enforcement.go @@ -43,6 +43,7 @@ func (s *Server) validateManageEntityAuth(ctx context.Context, rules config.Rule // cid it has no claim to is rejected without leaving the entity behind in // the overlay for later transactions in the proposal to build on. if rules.ContentAuthEnforced { + tx.ClaimUnattested = true if err := validateTrackContentAuth(ctx, overlay, tx); err != nil { if isAuthValidationError(err) { return &authRejectionError{reason: err.Error()} diff --git a/pkg/core/server/auth_state.go b/pkg/core/server/auth_state.go index d6d94467..50c6526e 100644 --- a/pkg/core/server/auth_state.go +++ b/pkg/core/server/auth_state.go @@ -91,6 +91,13 @@ type authTx struct { Action string Signer string Migration bool + // ClaimUnattested lets a live track write take the claim on a cid nobody + // holds yet (content_auth_state.go projectAssertedTrackCids). Set by the + // callers that know the ruleset, and only under Rules.ContentAuthEnforced: + // before the gate a chain must accumulate no claims from live traffic, or + // activating enforcement later would trust state built from unverified + // assertions. + ClaimUnattested bool meta map[string]any } @@ -310,7 +317,21 @@ func applyAuthProjection(ctx context.Context, st authStore, tx authTx) error { // and cids left unseeded there make enforcement unactivatable. Runs // only after the entity itself projects: a track that was skipped must // not leave claims behind. - return projectMigratedTrackCids(ctx, st, tx) + if err := projectMigratedTrackCids(ctx, st, tx); err != nil { + return err + } + return projectAssertedTrackCids(ctx, st, tx) + case entityType == authEntityTypeTrack && action == authActionUpdate: + // Track updates are otherwise untracked (ownership on update is the + // ETL's rule); the only projection is the first-assertion claim, and + // it requires the same signer authority a create does. + if !tx.ClaimUnattested || tx.Migration { + return nil + } + if err := validateAuthSigner(ctx, st, tx.UserID, tx.Signer); err != nil { + return err + } + return projectAssertedTrackCids(ctx, st, tx) case (entityType == authEntityTypeTrack || entityType == authEntityTypePlaylist) && action == authActionDelete: return projectEntityDelete(ctx, st, tx, entityType) case entityType == authEntityTypeGrant && action == authActionCreate: diff --git a/pkg/core/server/content_auth_state.go b/pkg/core/server/content_auth_state.go index 2aea43ea..52cf559f 100644 --- a/pkg/core/server/content_auth_state.go +++ b/pkg/core/server/content_auth_state.go @@ -91,8 +91,18 @@ func projectMigratedTrackCids(ctx context.Context, st authStore, tx authTx) erro } // validateTrackContentAuth is the enforcement check, active only under -// Rules.ContentAuthEnforced: every cid the transaction asserts must be recorded -// to a wallet authorized to act for the writing user. +// Rules.ContentAuthEnforced: no cid the transaction asserts may be held by a +// user other than the writing user. +// +// A cid nobody holds passes, and the projection then records it to the writer +// (projectAssertedTrackCids): first assertion wins. The attack this closes is +// naming a cid that belongs to someone else, and every cid that can belong to +// someone else is already claimed — migrated tracks are seeded by the replay +// and uploads to an enforcing node are attested before the upload reads done. +// What an unclaimed cid can be is audio that reached storage without an +// attestation: uploads made to nodes before they ran the gate, and multipart +// uploads that named no user. Refusing those would strand every such track, +// and the write that names them first is, in practice, its creator's. // // Only cids present in this transaction are checked, so metadata-only edits on // a track whose audio predates the projection keep working, and an audio @@ -131,9 +141,41 @@ func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx) err return err } if !known { - return authValidationErrorf("%s %q is not attested to any uploader", key, cid) + continue } return authValidationErrorf("%s %q was not uploaded for user %d", key, cid, tx.UserID) } return nil } + +// projectAssertedTrackCids records the writing user as claimant of every cid +// the transaction names that nobody holds yet — the projection half of +// first-assertion-wins (see validateTrackContentAuth). A cid someone already +// holds is left alone: at proposal time validation has rejected the write, +// and at finalize a block from a proposer without the gate must not hand a +// claim to whoever named the cid. +// +// Runs only for live writes on a chain where enforcement is active +// (tx.ClaimUnattested); migration rows are seeded by projectMigratedTrackCids. +func projectAssertedTrackCids(ctx context.Context, st authStore, tx authTx) error { + if !tx.ClaimUnattested || tx.Migration || tx.EntityType != authEntityTypeTrack { + return nil + } + for _, key := range trackCidMetadataKeys { + cid := tx.metaString(key) + if cid == "" { + continue + } + known, err := st.CidIsClaimed(ctx, cid) + if err != nil { + return err + } + if known { + continue + } + if err := st.InsertCid(ctx, cid, tx.UserID, ""); err != nil { + return err + } + } + return nil +} diff --git a/pkg/core/server/content_auth_state_test.go b/pkg/core/server/content_auth_state_test.go index 63f3ea78..4a1f64f3 100644 --- a/pkg/core/server/content_auth_state_test.go +++ b/pkg/core/server/content_auth_state_test.go @@ -140,13 +140,92 @@ func TestContentAuthRejectsClaimingAnotherUsersCid(t *testing.T) { mustRejectContentAuth(t, st, decoy, "was not uploaded for user 2") } -// A cid nobody has attested cannot be asserted at all. -func TestContentAuthRejectsUnattestedCid(t *testing.T) { +// A cid nobody holds goes to whoever names it first: the write passes and the +// projection records the writer as claimant, after which it is theirs alone. +// This is what lets audio that reached storage without an attestation — an +// upload to a node before it ran the gate, or a multipart upload naming no +// user — still be published by its creator. +func TestContentAuthUnattestedCidGoesToFirstAsserter(t *testing.T) { + ctx := context.Background() st := newMemAuthStore() mustProject(t, st, userCreateTx(1, "0xartist", "artist")) + mustProject(t, st, userCreateTx(2, "0xthief", "thief")) + + first := trackCidTx(1, 2_000_001, "0xartist", "Create", map[string]any{"track_cid": "nobody-attested-this"}) + first.ClaimUnattested = true + mustContentAuth(t, st, first) + mustProject(t, st, first) + if ok, _ := st.IsCidClaimedByUser(ctx, "nobody-attested-this", 1); !ok { + t.Fatal("the first asserter must hold the claim") + } - tx := trackCidTx(1, 2_000_001, "0xartist", "Create", map[string]any{"track_cid": "nobody-attested-this"}) - mustRejectContentAuth(t, st, tx, "is not attested to any uploader") + second := trackCidTx(2, 2_000_002, "0xthief", "Create", map[string]any{"track_cid": "nobody-attested-this"}) + second.ClaimUnattested = true + mustRejectContentAuth(t, st, second, "was not uploaded for user 2") + + edit := trackCidTx(1, 2_000_001, "0xartist", "Update", map[string]any{"track_cid": "nobody-attested-this"}) + edit.ClaimUnattested = true + mustContentAuth(t, st, edit) +} + +// The projection never hands out a claim someone already holds, so a block +// built by a proposer without the gate cannot transfer a cid by naming it. +func TestAssertedClaimNeverOverridesAnExistingOne(t *testing.T) { + ctx := context.Background() + st := newMemAuthStore() + mustProject(t, st, userCreateTx(1, "0xartist", "artist")) + mustProject(t, st, userCreateTx(2, "0xthief", "thief")) + if err := projectContentAttestationCids(ctx, st, attestationFor(1, "0xv1", "victim-320"), "tx1"); err != nil { + t.Fatal(err) + } + + decoy := trackCidTx(2, 2_000_002, "0xthief", "Create", map[string]any{"track_cid": "victim-320"}) + decoy.ClaimUnattested = true + mustRejectContentAuth(t, st, decoy, "was not uploaded for user 2") + mustProject(t, st, decoy) // as finalize would apply it from an ungated proposer + if ok, _ := st.IsCidClaimedByUser(ctx, "victim-320", 2); ok { + t.Fatal("naming a held cid must not grant a claim") + } +} + +// Before the gate a chain must accumulate no claims from live traffic, or +// activating enforcement later would trust state built from unverified +// assertions. ClaimUnattested is how the caller says the gate is active. +func TestPreGateLiveTrackClaimsNothing(t *testing.T) { + st := newMemAuthStore() + mustProject(t, st, userCreateTx(1, "0xartist", "artist")) + + create := trackCidTx(1, 2_000_001, "0xartist", "Create", map[string]any{"track_cid": "pre-gate"}) + mustProject(t, st, create) + update := trackCidTx(1, 2_000_001, "0xartist", "Update", map[string]any{"track_cid": "pre-gate-edit"}) + mustProject(t, st, update) + if len(st.cids) != 0 { + t.Fatalf("live writes before the gate must claim nothing, got %v", st.cids) + } +} + +// An update claims on the same terms as a create: only when the signer is +// authorized for the track's user, so a stranger's update cannot claim. +func TestUpdateClaimsUnattestedCidOnlyForAuthorizedSigner(t *testing.T) { + ctx := context.Background() + st := newMemAuthStore() + mustProject(t, st, userCreateTx(1, "0xartist", "artist")) + + stranger := trackCidTx(1, 2_000_001, "0xstranger", "Update", map[string]any{"track_cid": "new-audio"}) + stranger.ClaimUnattested = true + if err := applyAuthProjection(ctx, st, stranger); err == nil || !isAuthValidationError(err) { + t.Fatalf("expected a signer rejection, got %v", err) + } + if ok, _ := st.IsCidClaimedByUser(ctx, "new-audio", 1); ok { + t.Fatal("an unauthorized update must not claim") + } + + owner := trackCidTx(1, 2_000_001, "0xartist", "Update", map[string]any{"track_cid": "new-audio"}) + owner.ClaimUnattested = true + mustProject(t, st, owner) + if ok, _ := st.IsCidClaimedByUser(ctx, "new-audio", 1); !ok { + t.Fatal("the owner's update must claim the cid") + } } // orig_file_cid is the download path — the lossless master — so it is checked @@ -404,11 +483,14 @@ func TestProjectContentAttestationCidsAccumulateAcrossAttestations(t *testing.T) if err := projectContentAttestationCids(ctx, st, attestation("0xval", "origcid", "320cid"), "tx1"); err != nil { t.Fatal(err) } - tx := trackCidTx(1, 10, "0xup", "Create", map[string]any{"track_cid": "320cid", "preview_cid": "latepreview"}) - mustRejectContentAuth(t, st, tx, "not attested to any uploader") - if err := projectContentAttestationCids(ctx, st, attestation("0xval", "latepreview"), "tx2"); err != nil { t.Fatal(err) } - mustContentAuth(t, st, tx) + if ok, _ := st.IsCidClaimedByUser(ctx, "320cid", 1); !ok { + t.Fatal("the earlier attestation's claim must survive the later one") + } + // Both attestations credit user 1, so the late preview is theirs to name + // and nobody else's. + mustContentAuth(t, st, trackCidTx(1, 10, "0xup", "Create", map[string]any{"track_cid": "320cid", "preview_cid": "latepreview"})) + mustRejectContentAuth(t, st, trackCidTx(2, 11, "0xother", "Create", map[string]any{"preview_cid": "latepreview"}), "was not uploaded for user 2") } diff --git a/pkg/core/server/manage_entity.go b/pkg/core/server/manage_entity.go index 24418cc8..0303d011 100644 --- a/pkg/core/server/manage_entity.go +++ b/pkg/core/server/manage_entity.go @@ -62,7 +62,7 @@ type trackMetadata struct { } `json:"data"` } -func (s *Server) finalizeManageEntity(ctx context.Context, stx *v1.SignedTransaction) (proto.Message, error) { +func (s *Server) finalizeManageEntity(ctx context.Context, stx *v1.SignedTransaction, blockHeight int64) (proto.Message, error) { tx, err := s.validateManageEntity(ctx, stx) if err != nil { return nil, fmt.Errorf("invalid manage entity: %v", err) @@ -81,7 +81,9 @@ func (s *Server) finalizeManageEntity(ctx context.Context, stx *v1.SignedTransac } } - s.projectManageEntityAuthState(ctx, authTxFromManageEntity(manageEntity)) + auth := authTxFromManageEntity(manageEntity) + auth.ClaimUnattested = s.config.Upgrades.RulesetAt(blockHeight).ContentAuthEnforced + s.projectManageEntityAuthState(ctx, auth) return manageEntity, nil } diff --git a/pkg/integration_tests/14_content_auth_test.go b/pkg/integration_tests/14_content_auth_test.go index 2f44b7ae..478f1422 100644 --- a/pkg/integration_tests/14_content_auth_test.go +++ b/pkg/integration_tests/14_content_auth_test.go @@ -90,13 +90,19 @@ func TestContentAuth(t *testing.T) { require.Contains(t, err.Error(), "was not uploaded for user") }) - t.Run("UnattestedCidIsRejected", func(t *testing.T) { + t.Run("UnattestedCidGoesToFirstAsserter", func(t *testing.T) { + // Audio that reached storage without an attestation (an upload before + // the node ran the gate) is publishable by whoever names it first, and + // theirs alone from then on. + unattested := map[string]any{"track_cid": "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"} _, err := sendManageEntity(ctx, chainNode, owner, "Track", nextTrackID(), "Create", - trackMetadata(owner, "Never uploaded", map[string]any{ - "track_cid": "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", - })) + trackMetadata(owner, "Never attested", unattested)) + require.NoError(t, err) + + _, err = sendManageEntity(ctx, chainNode, thief, "Track", nextTrackID(), "Create", + trackMetadata(thief, "Decoy of never attested", unattested)) require.Error(t, err) - require.Contains(t, err.Error(), "is not attested to any uploader") + require.Contains(t, err.Error(), "was not uploaded for user") }) // Track updates are not projected at consensus (ownership on update is the From 58234e9bbc0fcfd2658cfb4fa855f21c3c0d5ac3 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:41:28 -0700 Subject: [PATCH 2/4] feat(core): close the first-assertion window with a strict content-auth height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-assertion-wins exists for the genesis migration: the API flusher replays old-chain track writes naming cids nobody attested and stalls on the first refused row. It is not safe as a steady state. The upload row is public on GET /uploads while it transcodes, so orig_file_cid is readable before any attestation exists, and the attestation itself is visible in the mempool before it lands. A ContentAttestation does not project into the proposal overlay, so a create naming the cid passes validation whichever side of the attestation it sits in the block, and the writer ends up with an indexed track naming someone else's audio — the decoy content auth exists to prevent. The scheduler has no end height, so add ContentAuthStrictHeight as a second gate over the same rule. Enforced but not strict: an unclaimed cid passes and the writer takes the claim. Strict: an unclaimed cid is refused with "is not attested to any uploader" and live writes claim nothing. Both proposal-time and finalize-time callers derive ClaimUnattested from the pair, so the two sides cannot disagree. Devnet and sandbox are strict from height 1, so the integration case goes back to UnattestedCidIsRejected; the window is covered by unit tests. The prod schedule must set the strict height once the flusher has drained, and never before. Co-Authored-By: Claude Fable 5.1 --- pkg/core/config/upgrades.go | 21 +++++++++++ pkg/core/config/upgrades_test.go | 26 +++++++++++++- pkg/core/server/auth_enforcement.go | 2 +- pkg/core/server/auth_state.go | 11 +++--- pkg/core/server/content_auth_state.go | 36 +++++++++++-------- pkg/core/server/content_auth_state_test.go | 16 +++++++++ pkg/core/server/manage_entity.go | 3 +- pkg/integration_tests/14_content_auth_test.go | 19 +++++----- 8 files changed, 100 insertions(+), 34 deletions(-) diff --git a/pkg/core/config/upgrades.go b/pkg/core/config/upgrades.go index 1cf6b71b..27eaa5fc 100644 --- a/pkg/core/config/upgrades.go +++ b/pkg/core/config/upgrades.go @@ -47,6 +47,22 @@ type UpgradeSchedule struct { // activation point: the migration replay seeds a cid for every track that // exists. ContentAuthEnforcementHeight int64 + + // ContentAuthStrictHeight closes the first-assertion window that + // ContentAuthEnforcementHeight opens. Between the two heights a cid nobody + // holds goes to whoever asserts it first; from this height a Track + // create/update may only assert cids the content-auth state already + // records as the acting user's, and a cid nobody holds is refused. + // + // The window exists for the genesis migration: the API flusher replays + // old-chain track writes naming cids nobody attested (old-chain storage + // never did, and the replay seeds only the snapshot's tracks) and stalls + // on the first refused row. It is not safe to leave open — the upload row + // is public while it transcodes and the attestation is visible in the + // mempool before it lands, so anyone who reads a cid there can take the + // claim by naming it first. Schedule strict as soon as the flusher has + // drained; never before, or it stalls. + ContentAuthStrictHeight int64 } // Rules is the resolved rule set for a single height: a flat description of @@ -54,6 +70,8 @@ type UpgradeSchedule struct { type Rules struct { AuthEnforced bool ContentAuthEnforced bool + // ContentAuthStrict only means anything with ContentAuthEnforced. + ContentAuthStrict bool } // RulesetAt resolves the rules governing the given block height. A nil @@ -65,6 +83,7 @@ func (u *UpgradeSchedule) RulesetAt(height int64) Rules { return Rules{ AuthEnforced: activeAt(u.AuthEnforcementHeight, height), ContentAuthEnforced: activeAt(u.ContentAuthEnforcementHeight, height), + ContentAuthStrict: activeAt(u.ContentAuthStrictHeight, height), } } @@ -82,11 +101,13 @@ var upgradeSchedules = map[string]*UpgradeSchedule{ "openaudio-devnet": { AuthEnforcementHeight: 1, ContentAuthEnforcementHeight: 1, + ContentAuthStrictHeight: 1, }, // sandbox "audius-devnet": { AuthEnforcementHeight: 1, ContentAuthEnforcementHeight: 1, + ContentAuthStrictHeight: 1, }, // stage "audius-testnet-alpha": {}, diff --git a/pkg/core/config/upgrades_test.go b/pkg/core/config/upgrades_test.go index 952689ba..5ac18fa6 100644 --- a/pkg/core/config/upgrades_test.go +++ b/pkg/core/config/upgrades_test.go @@ -26,6 +26,26 @@ func TestRulesetAtZeroMeansNever(t *testing.T) { } } +// Strict content auth is a second height over the same gate: enforcement +// alone opens the first-assertion window, strict closes it, and a schedule +// that activates both at one height never opens it. +func TestContentAuthStrictCloseWindow(t *testing.T) { + u := &UpgradeSchedule{ContentAuthEnforcementHeight: 10, ContentAuthStrictHeight: 20} + if r := u.RulesetAt(9); r.ContentAuthEnforced || r.ContentAuthStrict { + t.Fatal("nothing should be active before enforcement") + } + if r := u.RulesetAt(15); !r.ContentAuthEnforced || r.ContentAuthStrict { + t.Fatal("between the heights the window is open: enforced, not strict") + } + if r := u.RulesetAt(20); !r.ContentAuthEnforced || !r.ContentAuthStrict { + t.Fatal("at the strict height the window is closed") + } + same := &UpgradeSchedule{ContentAuthEnforcementHeight: 1, ContentAuthStrictHeight: 1} + if r := same.RulesetAt(1); !r.ContentAuthEnforced || !r.ContentAuthStrict { + t.Fatal("equal heights must be strict from the first enforced block") + } +} + // A nil schedule (tests, manually built configs) resolves to no active // upgrades instead of panicking. func TestRulesetAtNilSchedule(t *testing.T) { @@ -40,9 +60,13 @@ func TestRulesetAtNilSchedule(t *testing.T) { // must never inherit another network's activations. func TestScheduleForChainID(t *testing.T) { for _, chainID := range []string{"openaudio-devnet", "audius-devnet"} { - if !ScheduleForChainID(chainID).RulesetAt(1).AuthEnforced { + r := ScheduleForChainID(chainID).RulesetAt(1) + if !r.AuthEnforced { t.Fatalf("%s: auth enforcement should be active from height 1", chainID) } + if !r.ContentAuthEnforced || !r.ContentAuthStrict { + t.Fatalf("%s: content auth should be strict from height 1, got %+v", chainID, r) + } } for _, chainID := range []string{"audius-testnet-alpha", "audius-mainnet-alpha-beta", "some-future-chain"} { if ScheduleForChainID(chainID).RulesetAt(1<<40) != (Rules{}) { diff --git a/pkg/core/server/auth_enforcement.go b/pkg/core/server/auth_enforcement.go index 8c7f3284..edc9b4ce 100644 --- a/pkg/core/server/auth_enforcement.go +++ b/pkg/core/server/auth_enforcement.go @@ -43,7 +43,7 @@ func (s *Server) validateManageEntityAuth(ctx context.Context, rules config.Rule // cid it has no claim to is rejected without leaving the entity behind in // the overlay for later transactions in the proposal to build on. if rules.ContentAuthEnforced { - tx.ClaimUnattested = true + tx.ClaimUnattested = !rules.ContentAuthStrict if err := validateTrackContentAuth(ctx, overlay, tx); err != nil { if isAuthValidationError(err) { return &authRejectionError{reason: err.Error()} diff --git a/pkg/core/server/auth_state.go b/pkg/core/server/auth_state.go index 50c6526e..f2cc1dc5 100644 --- a/pkg/core/server/auth_state.go +++ b/pkg/core/server/auth_state.go @@ -91,12 +91,13 @@ type authTx struct { Action string Signer string Migration bool - // ClaimUnattested lets a live track write take the claim on a cid nobody - // holds yet (content_auth_state.go projectAssertedTrackCids). Set by the - // callers that know the ruleset, and only under Rules.ContentAuthEnforced: - // before the gate a chain must accumulate no claims from live traffic, or + // ClaimUnattested lets a live track write name, and take the claim on, a + // cid nobody holds yet (content_auth_state.go validateTrackContentAuth and + // projectAssertedTrackCids). Set by the callers that know the ruleset, and + // only under Rules.ContentAuthEnforced before Rules.ContentAuthStrict. + // Before the gate a chain must accumulate no claims from live traffic, or // activating enforcement later would trust state built from unverified - // assertions. + // assertions; under strict an unclaimed cid is refused outright. ClaimUnattested bool meta map[string]any diff --git a/pkg/core/server/content_auth_state.go b/pkg/core/server/content_auth_state.go index 52cf559f..05c9c2ad 100644 --- a/pkg/core/server/content_auth_state.go +++ b/pkg/core/server/content_auth_state.go @@ -92,17 +92,20 @@ func projectMigratedTrackCids(ctx context.Context, st authStore, tx authTx) erro // validateTrackContentAuth is the enforcement check, active only under // Rules.ContentAuthEnforced: no cid the transaction asserts may be held by a -// user other than the writing user. +// user other than the writing user, and once Rules.ContentAuthStrict is +// active every cid must be held by the writing user. // -// A cid nobody holds passes, and the projection then records it to the writer -// (projectAssertedTrackCids): first assertion wins. The attack this closes is -// naming a cid that belongs to someone else, and every cid that can belong to -// someone else is already claimed — migrated tracks are seeded by the replay -// and uploads to an enforcing node are attested before the upload reads done. -// What an unclaimed cid can be is audio that reached storage without an -// attestation: uploads made to nodes before they ran the gate, and multipart -// uploads that named no user. Refusing those would strand every such track, -// and the write that names them first is, in practice, its creator's. +// Between the two heights (tx.ClaimUnattested) a cid nobody holds passes, and +// the projection then records it to the writer (projectAssertedTrackCids): +// first assertion wins. That window exists for the genesis migration, whose +// API flusher replays old-chain track writes naming cids nobody attested and +// stalls on the first refused row. It is not safe to leave open: the upload +// row is public while it transcodes and the attestation is visible in the +// mempool before it lands, so whoever reads a cid there can take the claim by +// naming it first. Strict closes it once the flusher drains, and from then on +// every cid a track may legitimately name is already claimed — migrated +// tracks are seeded by the replay and uploads to an enforcing node are +// attested before the upload reads done. // // Only cids present in this transaction are checked, so metadata-only edits on // a track whose audio predates the projection keep working, and an audio @@ -140,10 +143,12 @@ func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx) err if err != nil { return err } - if !known { - continue + if known { + return authValidationErrorf("%s %q was not uploaded for user %d", key, cid, tx.UserID) + } + if !tx.ClaimUnattested { + return authValidationErrorf("%s %q is not attested to any uploader", key, cid) } - return authValidationErrorf("%s %q was not uploaded for user %d", key, cid, tx.UserID) } return nil } @@ -155,8 +160,9 @@ func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx) err // and at finalize a block from a proposer without the gate must not hand a // claim to whoever named the cid. // -// Runs only for live writes on a chain where enforcement is active -// (tx.ClaimUnattested); migration rows are seeded by projectMigratedTrackCids. +// Runs only for live writes inside the first-assertion window +// (tx.ClaimUnattested); migration rows are seeded by projectMigratedTrackCids, +// and under strict there is nothing unclaimed to record. func projectAssertedTrackCids(ctx context.Context, st authStore, tx authTx) error { if !tx.ClaimUnattested || tx.Migration || tx.EntityType != authEntityTypeTrack { return nil diff --git a/pkg/core/server/content_auth_state_test.go b/pkg/core/server/content_auth_state_test.go index 4a1f64f3..188aad3e 100644 --- a/pkg/core/server/content_auth_state_test.go +++ b/pkg/core/server/content_auth_state_test.go @@ -168,6 +168,22 @@ func TestContentAuthUnattestedCidGoesToFirstAsserter(t *testing.T) { mustContentAuth(t, st, edit) } +// Under strict content auth the window is closed: a cid nobody holds is +// refused, and a write naming one leaves no claim behind. Callers signal +// strict by leaving ClaimUnattested unset. +func TestStrictContentAuthRejectsUnattestedCid(t *testing.T) { + ctx := context.Background() + st := newMemAuthStore() + mustProject(t, st, userCreateTx(1, "0xartist", "artist")) + + tx := trackCidTx(1, 2_000_001, "0xartist", "Create", map[string]any{"track_cid": "nobody-attested-this"}) + mustRejectContentAuth(t, st, tx, "is not attested to any uploader") + mustProject(t, st, tx) // finalize projects the entity; the claim must not follow + if ok, _ := st.CidIsClaimed(ctx, "nobody-attested-this"); ok { + t.Fatal("a strict-mode write must not leave a claim behind") + } +} + // The projection never hands out a claim someone already holds, so a block // built by a proposer without the gate cannot transfer a cid by naming it. func TestAssertedClaimNeverOverridesAnExistingOne(t *testing.T) { diff --git a/pkg/core/server/manage_entity.go b/pkg/core/server/manage_entity.go index 0303d011..8d483c65 100644 --- a/pkg/core/server/manage_entity.go +++ b/pkg/core/server/manage_entity.go @@ -82,7 +82,8 @@ func (s *Server) finalizeManageEntity(ctx context.Context, stx *v1.SignedTransac } auth := authTxFromManageEntity(manageEntity) - auth.ClaimUnattested = s.config.Upgrades.RulesetAt(blockHeight).ContentAuthEnforced + rules := s.config.Upgrades.RulesetAt(blockHeight) + auth.ClaimUnattested = rules.ContentAuthEnforced && !rules.ContentAuthStrict s.projectManageEntityAuthState(ctx, auth) return manageEntity, nil diff --git a/pkg/integration_tests/14_content_auth_test.go b/pkg/integration_tests/14_content_auth_test.go index 478f1422..42c42f32 100644 --- a/pkg/integration_tests/14_content_auth_test.go +++ b/pkg/integration_tests/14_content_auth_test.go @@ -90,19 +90,16 @@ func TestContentAuth(t *testing.T) { require.Contains(t, err.Error(), "was not uploaded for user") }) - t.Run("UnattestedCidGoesToFirstAsserter", func(t *testing.T) { - // Audio that reached storage without an attestation (an upload before - // the node ran the gate) is publishable by whoever names it first, and - // theirs alone from then on. - unattested := map[string]any{"track_cid": "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"} + t.Run("UnattestedCidIsRejected", func(t *testing.T) { + // The devnet schedules strict content auth from height 1, so the + // first-assertion window the genesis migration relies on is never + // open here; pkg/core/server's unit tests cover it. _, err := sendManageEntity(ctx, chainNode, owner, "Track", nextTrackID(), "Create", - trackMetadata(owner, "Never attested", unattested)) - require.NoError(t, err) - - _, err = sendManageEntity(ctx, chainNode, thief, "Track", nextTrackID(), "Create", - trackMetadata(thief, "Decoy of never attested", unattested)) + trackMetadata(owner, "Never uploaded", map[string]any{ + "track_cid": "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", + })) require.Error(t, err) - require.Contains(t, err.Error(), "was not uploaded for user") + require.Contains(t, err.Error(), "is not attested to any uploader") }) // Track updates are not projected at consensus (ownership on update is the From e1ad7749d3465ef932d15c3d183ec24d256e3373 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:53:34 -0700 Subject: [PATCH 3/4] refactor(core): pass the ruleset into the auth projection instead of tagging the tx Whether a live track write may take the claim on an unclaimed cid is a property of the height's rules, not of the transaction, so it does not belong on authTx. applyAuthProjection and validateTrackContentAuth now take the resolved config.Rules alongside the transaction, and firstAssertionOpen derives the answer in one place. Migration rows never branch on the rules and pass an empty ruleset. The behavior is unchanged. Co-Authored-By: Claude Fable 5.1 --- pkg/core/server/auth_enforcement.go | 5 +-- pkg/core/server/auth_state.go | 24 +++++------ pkg/core/server/auth_state_test.go | 23 +++++++++-- pkg/core/server/content_auth_state.go | 23 ++++++++--- pkg/core/server/content_auth_state_test.go | 48 ++++++++++++---------- pkg/core/server/manage_entity.go | 13 +++--- 6 files changed, 83 insertions(+), 53 deletions(-) diff --git a/pkg/core/server/auth_enforcement.go b/pkg/core/server/auth_enforcement.go index edc9b4ce..3a3a90e8 100644 --- a/pkg/core/server/auth_enforcement.go +++ b/pkg/core/server/auth_enforcement.go @@ -43,8 +43,7 @@ func (s *Server) validateManageEntityAuth(ctx context.Context, rules config.Rule // cid it has no claim to is rejected without leaving the entity behind in // the overlay for later transactions in the proposal to build on. if rules.ContentAuthEnforced { - tx.ClaimUnattested = !rules.ContentAuthStrict - if err := validateTrackContentAuth(ctx, overlay, tx); err != nil { + if err := validateTrackContentAuth(ctx, overlay, tx, rules); err != nil { if isAuthValidationError(err) { return &authRejectionError{reason: err.Error()} } @@ -52,7 +51,7 @@ func (s *Server) validateManageEntityAuth(ctx context.Context, rules config.Rule } } - err = applyAuthProjection(ctx, overlay, tx) + err = applyAuthProjection(ctx, overlay, tx, rules) if err == nil { return nil } diff --git a/pkg/core/server/auth_state.go b/pkg/core/server/auth_state.go index f2cc1dc5..1dc0878f 100644 --- a/pkg/core/server/auth_state.go +++ b/pkg/core/server/auth_state.go @@ -8,6 +8,7 @@ import ( "strings" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/OpenAudio/go-openaudio/pkg/core/config" "github.com/OpenAudio/go-openaudio/pkg/core/db" ) @@ -91,14 +92,6 @@ type authTx struct { Action string Signer string Migration bool - // ClaimUnattested lets a live track write name, and take the claim on, a - // cid nobody holds yet (content_auth_state.go validateTrackContentAuth and - // projectAssertedTrackCids). Set by the callers that know the ruleset, and - // only under Rules.ContentAuthEnforced before Rules.ContentAuthStrict. - // Before the gate a chain must accumulate no claims from live traffic, or - // activating enforcement later would trust state built from unverified - // assertions; under strict an unclaimed cid is refused outright. - ClaimUnattested bool meta map[string]any } @@ -294,7 +287,11 @@ func validateAuthSigner(ctx context.Context, st authReader, userID int64, signer // is not auth-tracked at all — the projection has no opinion there. An // authValidationError is the projection mirroring an ETL rejection; any other // error is a store failure. -func applyAuthProjection(ctx context.Context, st authStore, tx authTx) error { +// +// rules is the ruleset resolved for the height the transaction executes at. +// The projection branches on it only for the first-assertion cid claim +// (content_auth_state.go); migration rows ignore it. +func applyAuthProjection(ctx context.Context, st authStore, tx authTx, rules config.Rules) error { entityType, ok := canonicalAuthEntityType(tx.EntityType) if !ok { return nil @@ -321,18 +318,18 @@ func applyAuthProjection(ctx context.Context, st authStore, tx authTx) error { if err := projectMigratedTrackCids(ctx, st, tx); err != nil { return err } - return projectAssertedTrackCids(ctx, st, tx) + return projectAssertedTrackCids(ctx, st, tx, rules) case entityType == authEntityTypeTrack && action == authActionUpdate: // Track updates are otherwise untracked (ownership on update is the // ETL's rule); the only projection is the first-assertion claim, and // it requires the same signer authority a create does. - if !tx.ClaimUnattested || tx.Migration { + if tx.Migration || !firstAssertionOpen(rules) { return nil } if err := validateAuthSigner(ctx, st, tx.UserID, tx.Signer); err != nil { return err } - return projectAssertedTrackCids(ctx, st, tx) + return projectAssertedTrackCids(ctx, st, tx, rules) case (entityType == authEntityTypeTrack || entityType == authEntityTypePlaylist) && action == authActionDelete: return projectEntityDelete(ctx, st, tx, entityType) case entityType == authEntityTypeGrant && action == authActionCreate: @@ -651,7 +648,8 @@ func projectAppDelete(ctx context.Context, st authStore, tx authTx) error { // it, with reason describing why. A migration replays state the source system // already accepted, so any skip is a defect the caller should surface. func ProjectMigrationAuthState(ctx context.Context, q *db.Queries, me *v1.ManageEntityLegacyMigration) (skipped bool, reason string, err error) { - err = applyAuthProjection(ctx, &dbAuthStore{q: q}, authTxFromManageEntityMigration(me)) + // A migration row's projection does not branch on the ruleset. + err = applyAuthProjection(ctx, &dbAuthStore{q: q}, authTxFromManageEntityMigration(me), config.Rules{}) switch { case err == nil: return false, "", nil diff --git a/pkg/core/server/auth_state_test.go b/pkg/core/server/auth_state_test.go index 7a6801e4..6b3a8c76 100644 --- a/pkg/core/server/auth_state_test.go +++ b/pkg/core/server/auth_state_test.go @@ -6,6 +6,18 @@ import ( "testing" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/OpenAudio/go-openaudio/pkg/core/config" +) + +// Rulesets the projection tests run under. Most tests do not care and use +// the zero ruleset (pre-gate); the content-auth tests pick a side of the +// first-assertion window explicitly. +var ( + // windowRules: content auth enforced, not yet strict — an unclaimed cid + // goes to the first asserter. + windowRules = config.Rules{AuthEnforced: true, ContentAuthEnforced: true} + // strictRules: the steady state — every cid must already be claimed. + strictRules = config.Rules{AuthEnforced: true, ContentAuthEnforced: true, ContentAuthStrict: true} ) // memAuthStore is a map-backed authStore so projection logic tests run @@ -186,14 +198,19 @@ func (m *memAuthStore) SetEntityDeleted(_ context.Context, entityType string, en func mustProject(t *testing.T, st authStore, tx authTx) { t.Helper() - if err := applyAuthProjection(context.Background(), st, tx); err != nil { + mustProjectUnder(t, st, tx, config.Rules{}) +} + +func mustProjectUnder(t *testing.T, st authStore, tx authTx, rules config.Rules) { + t.Helper() + if err := applyAuthProjection(context.Background(), st, tx, rules); err != nil { t.Fatalf("expected projection to apply: %v", err) } } func mustSkip(t *testing.T, st authStore, tx authTx, wantReason string) { t.Helper() - err := applyAuthProjection(context.Background(), st, tx) + err := applyAuthProjection(context.Background(), st, tx, config.Rules{}) if err == nil { t.Fatalf("expected projection to skip (%s), but it applied", wantReason) } @@ -396,7 +413,7 @@ func TestProjectUntrackedPairIsNil(t *testing.T) { st := newMemAuthStore() if err := applyAuthProjection(context.Background(), st, authTx{ UserID: 1, EntityType: "Follow", Action: "Create", Signer: "0xw1", - }); err != nil { + }, config.Rules{}); err != nil { t.Fatalf("untracked pair must project to nil, got %v", err) } } diff --git a/pkg/core/server/content_auth_state.go b/pkg/core/server/content_auth_state.go index 05c9c2ad..572b3c12 100644 --- a/pkg/core/server/content_auth_state.go +++ b/pkg/core/server/content_auth_state.go @@ -5,6 +5,7 @@ import ( "strings" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/OpenAudio/go-openaudio/pkg/core/config" ) // Consensus-side content authorization (core_auth_cids): which user may @@ -90,12 +91,22 @@ func projectMigratedTrackCids(ctx context.Context, st authStore, tx authTx) erro return nil } +// firstAssertionOpen reports whether, under these rules, a live track write +// may name — and take the claim on — a cid nobody holds: content auth is +// enforced but not yet strict. Before the enforcement gate a chain must +// accumulate no claims from live traffic, or activating enforcement later +// would trust state built from unverified assertions; under strict an +// unclaimed cid is refused outright, so there is nothing to claim. +func firstAssertionOpen(rules config.Rules) bool { + return rules.ContentAuthEnforced && !rules.ContentAuthStrict +} + // validateTrackContentAuth is the enforcement check, active only under // Rules.ContentAuthEnforced: no cid the transaction asserts may be held by a // user other than the writing user, and once Rules.ContentAuthStrict is // active every cid must be held by the writing user. // -// Between the two heights (tx.ClaimUnattested) a cid nobody holds passes, and +// Between the two heights (firstAssertionOpen) a cid nobody holds passes, and // the projection then records it to the writer (projectAssertedTrackCids): // first assertion wins. That window exists for the genesis migration, whose // API flusher replays old-chain track writes naming cids nobody attested and @@ -110,7 +121,7 @@ func projectMigratedTrackCids(ctx context.Context, st authStore, tx authTx) erro // Only cids present in this transaction are checked, so metadata-only edits on // a track whose audio predates the projection keep working, and an audio // replacement is checked against the new cid alone. -func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx) error { +func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx, rules config.Rules) error { if tx.Migration || tx.EntityType != authEntityTypeTrack { return nil } @@ -146,7 +157,7 @@ func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx) err if known { return authValidationErrorf("%s %q was not uploaded for user %d", key, cid, tx.UserID) } - if !tx.ClaimUnattested { + if !firstAssertionOpen(rules) { return authValidationErrorf("%s %q is not attested to any uploader", key, cid) } } @@ -161,10 +172,10 @@ func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx) err // claim to whoever named the cid. // // Runs only for live writes inside the first-assertion window -// (tx.ClaimUnattested); migration rows are seeded by projectMigratedTrackCids, +// (firstAssertionOpen); migration rows are seeded by projectMigratedTrackCids, // and under strict there is nothing unclaimed to record. -func projectAssertedTrackCids(ctx context.Context, st authStore, tx authTx) error { - if !tx.ClaimUnattested || tx.Migration || tx.EntityType != authEntityTypeTrack { +func projectAssertedTrackCids(ctx context.Context, st authStore, tx authTx, rules config.Rules) error { + if tx.Migration || !firstAssertionOpen(rules) || tx.EntityType != authEntityTypeTrack { return nil } for _, key := range trackCidMetadataKeys { diff --git a/pkg/core/server/content_auth_state_test.go b/pkg/core/server/content_auth_state_test.go index 188aad3e..e804d6f7 100644 --- a/pkg/core/server/content_auth_state_test.go +++ b/pkg/core/server/content_auth_state_test.go @@ -6,6 +6,7 @@ import ( "testing" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/OpenAudio/go-openaudio/pkg/core/config" ) func trackCidTx(userID, trackID int64, signer, action string, cids map[string]any) authTx { @@ -32,16 +33,28 @@ func attestationFor(userID int64, validator string, cids ...string) *v1.ContentA } } +// The default helpers check under strict rules, the steady state; the +// first-assertion tests pass windowRules explicitly. func mustContentAuth(t *testing.T, st authReader, tx authTx) { t.Helper() - if err := validateTrackContentAuth(context.Background(), st, tx); err != nil { + mustContentAuthUnder(t, st, tx, strictRules) +} + +func mustContentAuthUnder(t *testing.T, st authReader, tx authTx, rules config.Rules) { + t.Helper() + if err := validateTrackContentAuth(context.Background(), st, tx, rules); err != nil { t.Fatalf("expected content auth to pass: %v", err) } } func mustRejectContentAuth(t *testing.T, st authReader, tx authTx, wantReason string) { t.Helper() - err := validateTrackContentAuth(context.Background(), st, tx) + mustRejectContentAuthUnder(t, st, tx, strictRules, wantReason) +} + +func mustRejectContentAuthUnder(t *testing.T, st authReader, tx authTx, rules config.Rules, wantReason string) { + t.Helper() + err := validateTrackContentAuth(context.Background(), st, tx, rules) if err == nil { t.Fatalf("expected content auth rejection (%s), but it passed", wantReason) } @@ -152,33 +165,29 @@ func TestContentAuthUnattestedCidGoesToFirstAsserter(t *testing.T) { mustProject(t, st, userCreateTx(2, "0xthief", "thief")) first := trackCidTx(1, 2_000_001, "0xartist", "Create", map[string]any{"track_cid": "nobody-attested-this"}) - first.ClaimUnattested = true - mustContentAuth(t, st, first) - mustProject(t, st, first) + mustContentAuthUnder(t, st, first, windowRules) + mustProjectUnder(t, st, first, windowRules) if ok, _ := st.IsCidClaimedByUser(ctx, "nobody-attested-this", 1); !ok { t.Fatal("the first asserter must hold the claim") } second := trackCidTx(2, 2_000_002, "0xthief", "Create", map[string]any{"track_cid": "nobody-attested-this"}) - second.ClaimUnattested = true - mustRejectContentAuth(t, st, second, "was not uploaded for user 2") + mustRejectContentAuthUnder(t, st, second, windowRules, "was not uploaded for user 2") edit := trackCidTx(1, 2_000_001, "0xartist", "Update", map[string]any{"track_cid": "nobody-attested-this"}) - edit.ClaimUnattested = true - mustContentAuth(t, st, edit) + mustContentAuthUnder(t, st, edit, windowRules) } // Under strict content auth the window is closed: a cid nobody holds is -// refused, and a write naming one leaves no claim behind. Callers signal -// strict by leaving ClaimUnattested unset. +// refused, and a write naming one leaves no claim behind. func TestStrictContentAuthRejectsUnattestedCid(t *testing.T) { ctx := context.Background() st := newMemAuthStore() mustProject(t, st, userCreateTx(1, "0xartist", "artist")) tx := trackCidTx(1, 2_000_001, "0xartist", "Create", map[string]any{"track_cid": "nobody-attested-this"}) - mustRejectContentAuth(t, st, tx, "is not attested to any uploader") - mustProject(t, st, tx) // finalize projects the entity; the claim must not follow + mustRejectContentAuthUnder(t, st, tx, strictRules, "is not attested to any uploader") + mustProjectUnder(t, st, tx, strictRules) // finalize projects the entity; the claim must not follow if ok, _ := st.CidIsClaimed(ctx, "nobody-attested-this"); ok { t.Fatal("a strict-mode write must not leave a claim behind") } @@ -196,9 +205,8 @@ func TestAssertedClaimNeverOverridesAnExistingOne(t *testing.T) { } decoy := trackCidTx(2, 2_000_002, "0xthief", "Create", map[string]any{"track_cid": "victim-320"}) - decoy.ClaimUnattested = true - mustRejectContentAuth(t, st, decoy, "was not uploaded for user 2") - mustProject(t, st, decoy) // as finalize would apply it from an ungated proposer + mustRejectContentAuthUnder(t, st, decoy, windowRules, "was not uploaded for user 2") + mustProjectUnder(t, st, decoy, windowRules) // as finalize would apply it from an ungated proposer if ok, _ := st.IsCidClaimedByUser(ctx, "victim-320", 2); ok { t.Fatal("naming a held cid must not grant a claim") } @@ -206,7 +214,7 @@ func TestAssertedClaimNeverOverridesAnExistingOne(t *testing.T) { // Before the gate a chain must accumulate no claims from live traffic, or // activating enforcement later would trust state built from unverified -// assertions. ClaimUnattested is how the caller says the gate is active. +// assertions. The zero ruleset is pre-gate. func TestPreGateLiveTrackClaimsNothing(t *testing.T) { st := newMemAuthStore() mustProject(t, st, userCreateTx(1, "0xartist", "artist")) @@ -228,8 +236,7 @@ func TestUpdateClaimsUnattestedCidOnlyForAuthorizedSigner(t *testing.T) { mustProject(t, st, userCreateTx(1, "0xartist", "artist")) stranger := trackCidTx(1, 2_000_001, "0xstranger", "Update", map[string]any{"track_cid": "new-audio"}) - stranger.ClaimUnattested = true - if err := applyAuthProjection(ctx, st, stranger); err == nil || !isAuthValidationError(err) { + if err := applyAuthProjection(ctx, st, stranger, windowRules); err == nil || !isAuthValidationError(err) { t.Fatalf("expected a signer rejection, got %v", err) } if ok, _ := st.IsCidClaimedByUser(ctx, "new-audio", 1); ok { @@ -237,8 +244,7 @@ func TestUpdateClaimsUnattestedCidOnlyForAuthorizedSigner(t *testing.T) { } owner := trackCidTx(1, 2_000_001, "0xartist", "Update", map[string]any{"track_cid": "new-audio"}) - owner.ClaimUnattested = true - mustProject(t, st, owner) + mustProjectUnder(t, st, owner, windowRules) if ok, _ := st.IsCidClaimedByUser(ctx, "new-audio", 1); !ok { t.Fatal("the owner's update must claim the cid") } diff --git a/pkg/core/server/manage_entity.go b/pkg/core/server/manage_entity.go index 8d483c65..f4ff484a 100644 --- a/pkg/core/server/manage_entity.go +++ b/pkg/core/server/manage_entity.go @@ -9,6 +9,7 @@ import ( "strings" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/OpenAudio/go-openaudio/pkg/core/config" "github.com/OpenAudio/go-openaudio/pkg/core/db" "github.com/jackc/pgx/v5/pgtype" "go.uber.org/zap" @@ -48,7 +49,8 @@ func (s *Server) finalizeManageEntityMigration(ctx context.Context, stx *v1.Sign } } - s.projectManageEntityAuthState(ctx, authTxFromManageEntityMigration(me)) + // A migration row's projection does not branch on the ruleset. + s.projectManageEntityAuthState(ctx, authTxFromManageEntityMigration(me), config.Rules{}) return me, nil } @@ -81,10 +83,7 @@ func (s *Server) finalizeManageEntity(ctx context.Context, stx *v1.SignedTransac } } - auth := authTxFromManageEntity(manageEntity) - rules := s.config.Upgrades.RulesetAt(blockHeight) - auth.ClaimUnattested = rules.ContentAuthEnforced && !rules.ContentAuthStrict - s.projectManageEntityAuthState(ctx, auth) + s.projectManageEntityAuthState(ctx, authTxFromManageEntity(manageEntity), s.config.Upgrades.RulesetAt(blockHeight)) return manageEntity, nil } @@ -95,9 +94,9 @@ func (s *Server) finalizeManageEntity(ctx context.Context, stx *v1.SignedTransac // expected; store errors are logged without failing the tx, matching how the // rest of FinalizeBlock treats db errors so a local infra hiccup cannot make // this node's tx results diverge from its peers'. -func (s *Server) projectManageEntityAuthState(ctx context.Context, tx authTx) { +func (s *Server) projectManageEntityAuthState(ctx context.Context, tx authTx, rules config.Rules) { store := &dbAuthStore{q: s.getDb()} - err := applyAuthProjection(ctx, store, tx) + err := applyAuthProjection(ctx, store, tx, rules) switch { case err == nil: case isAuthValidationError(err): From 4fa477414e5ef211edd8ccd2fd662f3b6bc901fc Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:01:12 -0700 Subject: [PATCH 4/4] refactor(core): read the content-auth rule fields directly Drop the firstAssertionOpen helper. Validation only runs under ContentAuthEnforced, so strict alone decides there. The projection runs for every live write, so it checks enforced explicitly too: before the gate a chain must accumulate no claims from live traffic. Behavior is unchanged. Co-Authored-By: Claude Fable 5.1 --- pkg/core/server/auth_state.go | 2 +- pkg/core/server/content_auth_state.go | 27 +++++++++++---------------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/pkg/core/server/auth_state.go b/pkg/core/server/auth_state.go index 1dc0878f..015d702a 100644 --- a/pkg/core/server/auth_state.go +++ b/pkg/core/server/auth_state.go @@ -323,7 +323,7 @@ func applyAuthProjection(ctx context.Context, st authStore, tx authTx, rules con // Track updates are otherwise untracked (ownership on update is the // ETL's rule); the only projection is the first-assertion claim, and // it requires the same signer authority a create does. - if tx.Migration || !firstAssertionOpen(rules) { + if tx.Migration || !rules.ContentAuthEnforced || rules.ContentAuthStrict { return nil } if err := validateAuthSigner(ctx, st, tx.UserID, tx.Signer); err != nil { diff --git a/pkg/core/server/content_auth_state.go b/pkg/core/server/content_auth_state.go index 572b3c12..2d712b3f 100644 --- a/pkg/core/server/content_auth_state.go +++ b/pkg/core/server/content_auth_state.go @@ -91,22 +91,12 @@ func projectMigratedTrackCids(ctx context.Context, st authStore, tx authTx) erro return nil } -// firstAssertionOpen reports whether, under these rules, a live track write -// may name — and take the claim on — a cid nobody holds: content auth is -// enforced but not yet strict. Before the enforcement gate a chain must -// accumulate no claims from live traffic, or activating enforcement later -// would trust state built from unverified assertions; under strict an -// unclaimed cid is refused outright, so there is nothing to claim. -func firstAssertionOpen(rules config.Rules) bool { - return rules.ContentAuthEnforced && !rules.ContentAuthStrict -} - // validateTrackContentAuth is the enforcement check, active only under // Rules.ContentAuthEnforced: no cid the transaction asserts may be held by a // user other than the writing user, and once Rules.ContentAuthStrict is // active every cid must be held by the writing user. // -// Between the two heights (firstAssertionOpen) a cid nobody holds passes, and +// Between the two heights a cid nobody holds passes, and // the projection then records it to the writer (projectAssertedTrackCids): // first assertion wins. That window exists for the genesis migration, whose // API flusher replays old-chain track writes naming cids nobody attested and @@ -157,7 +147,8 @@ func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx, rul if known { return authValidationErrorf("%s %q was not uploaded for user %d", key, cid, tx.UserID) } - if !firstAssertionOpen(rules) { + // Only reached under ContentAuthEnforced, so strict alone decides. + if rules.ContentAuthStrict { return authValidationErrorf("%s %q is not attested to any uploader", key, cid) } } @@ -171,11 +162,15 @@ func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx, rul // and at finalize a block from a proposer without the gate must not hand a // claim to whoever named the cid. // -// Runs only for live writes inside the first-assertion window -// (firstAssertionOpen); migration rows are seeded by projectMigratedTrackCids, -// and under strict there is nothing unclaimed to record. +// Runs only for live writes inside the first-assertion window: content auth +// enforced but not yet strict. Unlike validation this runs for every live +// write, so the enforced check is explicit — before the gate a chain must +// accumulate no claims from live traffic, or activating enforcement later +// would trust state built from unverified assertions. Under strict there is +// nothing unclaimed to record. Migration rows are seeded by +// projectMigratedTrackCids. func projectAssertedTrackCids(ctx context.Context, st authStore, tx authTx, rules config.Rules) error { - if tx.Migration || !firstAssertionOpen(rules) || tx.EntityType != authEntityTypeTrack { + if tx.Migration || !rules.ContentAuthEnforced || rules.ContentAuthStrict || tx.EntityType != authEntityTypeTrack { return nil } for _, key := range trackCidMetadataKeys {