package console // Pending changes: multi-party quorum for the actions release-pipelines-v2 // gates (spec §4) — promote, policy, fasttrack. A change is proposed once, // with its payload pinned at propose time (canonical JSON — Task 8 re-checks // nothing here, it only ever executes the payload this row already holds), // and members confirm it until a quorum is met. // // The load-bearing rule (spec §4, controller ruling): a BEARER TOKEN NEVER // COUNTS TOWARD A PASSKEY QUORUM. A console/step-up proposal has already // proved presence, so its proposer is recorded as the first confirmation in // the same transaction that creates the row. A CLI bearer-token proposal has // proved nothing of the kind, so it starts at zero confirmations — even a // quorum of 1 needs a SEPARATE, passkey-backed confirmation before it is // met, including one made by the same identity that proposed it over the // CLI. import ( "context" "crypto/rand" "database/sql" "encoding/hex" "errors" "fmt" "time" ) // PendingChangeTTL bounds how long a proposed change waits for quorum before // it can no longer be confirmed or executed. const PendingChangeTTL = 7 * 24 * time.Hour // ConfirmChange's three refusals, as values. The console maps them onto the // wire (refuseConfirm: 404 / 410 / 409), and it does so with errors.Is — the // substring matching this replaced meant a reworded message silently became a // 500 telling a member to "try again" about a change that can never succeed. // A refusal that has to be recognised by another package's grep is not a // contract; these are. // // Each is wrapped with the change's id at the raise site, so the log and the // operator keep the detail while callers keep the value. var ( ErrNoPendingChange = errors.New("console: no such pending change") ErrPendingChangeExpired = errors.New("console: pending change has expired") ErrPendingChangeExecuted = errors.New("console: pending change has already been executed") ) // PendingChange is one proposed, quorum-gated action. type PendingChange struct { ID string AccountID int64 App string Kind string // "promote" | "policy" | "fasttrack" Payload []byte // canonical JSON, pinned at propose time Quorum int Proposer string CreatedAt time.Time ExpiresAt time.Time Confirms []string // addresses, proposer included when passkey-backed } // migratePendingChanges adds this feature's two tables. Additive and // idempotent, and kept here rather than in store.go's list so the feature is // one file to read and one file to remove (the authstore.go / stepup.go // precedent). func (s *Store) migratePendingChanges() error { for _, stmt := range []string{ `CREATE TABLE IF NOT EXISTS pending_changes ( id TEXT PRIMARY KEY, account_id INTEGER NOT NULL, app TEXT NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, quorum INTEGER NOT NULL, proposer TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, executed_at INTEGER NOT NULL DEFAULT 0 );`, `CREATE TABLE IF NOT EXISTS pending_confirms ( change_id TEXT NOT NULL, address TEXT NOT NULL, confirmed_at INTEGER NOT NULL, PRIMARY KEY (change_id, address) );`, } { if _, err := s.db.Exec(stmt); err != nil { return err } } return nil } // newChangeID mints a pending-change id: 16 bytes of crypto/rand, hex — // the same shape as the keymail envelope id (keymail.go), unguessable and // URL-safe with no encoding fuss. func newChangeID() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // ProposeChange inserts a change; proposerConfirmed records the proposer's // confirmation row (true only for console/step-up proposals — spec §4: a // bearer token never counts toward a passkey quorum). One transaction: a // console/step-up proposal's row and its first confirmation must never be // separable by a crash, or a reader could observe a change credited to // nobody despite the proposer having just proved presence for it. func (s *Store) claimRawProposeChange(accountID int64, app, kind string, payload []byte, quorum int, proposer string, proposerConfirmed bool, now time.Time) (*PendingChange, error) { id, err := newChangeID() if err != nil { return nil, err } created := now.Unix() expires := now.Add(PendingChangeTTL).Unix() tx, err := s.db.Begin() if err != nil { return nil, err } defer tx.Rollback() if _, err := tx.Exec(`INSERT INTO pending_changes (id, account_id, app, kind, payload, quorum, proposer, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, accountID, app, kind, payload, quorum, proposer, created, expires); err != nil { return nil, err } var confirms []string if proposerConfirmed { if _, err := tx.Exec(`INSERT INTO pending_confirms (change_id, address, confirmed_at) VALUES (?, ?, ?)`, id, proposer, created); err != nil { return nil, err } confirms = []string{proposer} } if err := tx.Commit(); err != nil { return nil, err } return &PendingChange{ ID: id, AccountID: accountID, App: app, Kind: kind, Payload: payload, Quorum: quorum, Proposer: proposer, CreatedAt: time.Unix(created, 0).UTC(), ExpiresAt: time.Unix(expires, 0).UTC(), Confirms: confirms, }, nil } // ConfirmChange records one member's confirmation (idempotent per address — // INSERT OR IGNORE, then a recount, so confirming twice never inflates the // tally) and reports whether quorum is now met, including when it was // already met before this call. Expired (now >= ExpiresAt) or executed // changes refuse outright: neither state accepts a new confirmation. One // transaction throughout, so the read that decides "expired"/"executed" and // the write that records the confirmation can never straddle a concurrent // MarkExecuted or a second confirmation. func (s *Store) ConfirmChange(id, address string, now time.Time) (*PendingChange, bool, error) { tx, err := s.db.Begin() if err != nil { return nil, false, err } defer tx.Rollback() pc, err := loadPendingChange(tx, id) if err != nil { return nil, false, err } if pc == nil { return nil, false, fmt.Errorf("%w: %q", ErrNoPendingChange, id) } if now.Unix() >= pc.ExpiresAt.Unix() { return nil, false, fmt.Errorf("%w: %q", ErrPendingChangeExpired, id) } if !executedAtZero(tx, id) { return nil, false, fmt.Errorf("%w: %q", ErrPendingChangeExecuted, id) } if _, err := tx.Exec(`INSERT OR IGNORE INTO pending_confirms (change_id, address, confirmed_at) VALUES (?, ?, ?)`, id, address, now.Unix()); err != nil { return nil, false, err } confirms, err := confirmsFor(tx, id) if err != nil { return nil, false, err } pc.Confirms = confirms if err := tx.Commit(); err != nil { return nil, false, err } return pc, len(confirms) >= pc.Quorum, nil } // PendingChangesFor lists an account/app's unexpired, unexecuted changes. func (s *Store) claimRawPendingChangesFor(accountID int64, app string, now time.Time) ([]PendingChange, error) { tx, err := s.db.Begin() if err != nil { return nil, err } defer tx.Rollback() rows, err := tx.Query(`SELECT id, account_id, app, kind, payload, quorum, proposer, created_at, expires_at FROM pending_changes WHERE account_id = ? AND app = ? AND executed_at = 0 AND expires_at > ? ORDER BY created_at, id`, accountID, app, now.Unix()) if err != nil { return nil, err } var ids []string var out []PendingChange for rows.Next() { var pc PendingChange var created, expires int64 if err := rows.Scan(&pc.ID, &pc.AccountID, &pc.App, &pc.Kind, &pc.Payload, &pc.Quorum, &pc.Proposer, &created, &expires); err != nil { rows.Close() return nil, err } pc.CreatedAt = time.Unix(created, 0).UTC() pc.ExpiresAt = time.Unix(expires, 0).UTC() out = append(out, pc) ids = append(ids, pc.ID) } rows.Close() if err := rows.Err(); err != nil { return nil, err } for i, id := range ids { confirms, err := confirmsFor(tx, id) if err != nil { return nil, err } out[i].Confirms = confirms } if err := tx.Commit(); err != nil { return nil, err } return out, nil } // PendingChange fetches one change, excluding executed and expired rows — // both read as (nil, nil), the same "not found" a caller gets for an id that // was never proposed at all. func (s *Store) PendingChange(id string, now time.Time) (*PendingChange, error) { tx, err := s.db.Begin() if err != nil { return nil, err } defer tx.Rollback() pc, err := loadPendingChange(tx, id) if err != nil { return nil, err } if pc == nil { return nil, nil } if now.Unix() >= pc.ExpiresAt.Unix() { return nil, nil } if !executedAtZero(tx, id) { return nil, nil } confirms, err := confirmsFor(tx, id) if err != nil { return nil, err } pc.Confirms = confirms if err := tx.Commit(); err != nil { return nil, err } return pc, nil } // MemberConfirms is a change's confirming addresses filtered to those that // are STILL members of the account — the tally EXECUTION must count (spec §4: // "quorum is N distinct MEMBERS"). A confirmation is an act of standing // authority, and an address that has left the account has none: without this // filter, removing somebody from an account would leave their approvals // working on their way out, and an account could be brought to quorum by // people who are no longer in it. // // The rows are KEPT, not deleted. Who confirmed what is a true fact about the // past and the trail says so; what changes is only whether it still counts. // Confirms (the display tally on the card and the CLI list) deliberately // keeps showing every row — the gap between the two numbers is the honest // picture, and a card that quietly dropped a name would make a stuck change // unexplainable. func (s *Store) MemberConfirms(accountID int64, id string) ([]string, error) { rows, err := s.db.Query(`SELECT c.address FROM pending_confirms c JOIN memberships m ON m.address = c.address AND m.account_id = ? WHERE c.change_id = ? ORDER BY c.confirmed_at, c.address`, accountID, id) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var a string if err := rows.Scan(&a); err != nil { return nil, err } out = append(out, a) } return out, rows.Err() } // MarkExecuted stamps a change as carried out, so it stops appearing as // pending and can no longer be confirmed. func (s *Store) MarkExecuted(id string, now time.Time) error { _, err := s.db.Exec(`UPDATE pending_changes SET executed_at = ? WHERE id = ? AND executed_at = 0`, now.Unix(), id) return err } // DeleteChange removes a change outright — reject/cancel. Its confirmations // go with it: a deleted change is gone, not merely emptied. func (s *Store) DeleteChange(id string) error { tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM pending_confirms WHERE change_id = ?`, id); err != nil { return err } if _, err := tx.Exec(`DELETE FROM pending_changes WHERE id = ?`, id); err != nil { return err } return tx.Commit() } // ---- helpers ---------------------------------------------------------------- // loadPendingChange reads a change row by id, unfiltered by expiry or // execution — the callers above apply those rules themselves, since // ConfirmChange needs to distinguish "expired" from "executed" for its error // message and PendingChange/PendingChangesFor need to treat both as absence. // Confirms is left nil; callers fill it via confirmsFor. func loadPendingChange(tx *sql.Tx, id string) (*PendingChange, error) { var pc PendingChange var created, expires int64 err := tx.QueryRow(`SELECT id, account_id, app, kind, payload, quorum, proposer, created_at, expires_at FROM pending_changes WHERE id = ?`, id). Scan(&pc.ID, &pc.AccountID, &pc.App, &pc.Kind, &pc.Payload, &pc.Quorum, &pc.Proposer, &created, &expires) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } pc.CreatedAt = time.Unix(created, 0).UTC() pc.ExpiresAt = time.Unix(expires, 0).UTC() return &pc, nil } // executedAtZero reports whether a change's executed_at is still 0 (i.e. it // has not been executed). A missing row reads as "not executed" — the // caller has already confirmed the row exists via loadPendingChange before // this is asked. func executedAtZero(tx *sql.Tx, id string) bool { var executedAt int64 if err := tx.QueryRow(`SELECT executed_at FROM pending_changes WHERE id = ?`, id).Scan(&executedAt); err != nil { return true // let the caller's own row-existence check own "not found" } return executedAt == 0 } // confirmsFor returns a change's confirming addresses, sorted by when they // confirmed. func confirmsFor(tx *sql.Tx, id string) ([]string, error) { rows, err := tx.Query(`SELECT address FROM pending_confirms WHERE change_id = ? ORDER BY confirmed_at, address`, id) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var a string if err := rows.Scan(&a); err != nil { return nil, err } out = append(out, a) } return out, rows.Err() } func (s *Store) PendingChangesFor(accountID int64, app string, now time.Time) (claimResult0 []PendingChange, claimResult1 error) { return s.PendingChangesForContext(context.Background(), accountID, app, now) } // PendingChangesForContext is a read: its transaction only SELECTs, so it // takes no app mutation. Taking one would make every page and CLI list that // shows the pending card register with the gate and refuse under a foreign // operation. The armed triggers still refuse any write it could ever grow. func (s *Store) PendingChangesForContext(claimContext context.Context, accountID int64, app string, now time.Time) (claimResult0 []PendingChange, claimResult1 error) { return s.claimRawPendingChangesFor(accountID, app, now) } func (s *Store) ProposeChange(accountID int64, app, kind string, payload []byte, quorum int, proposer string, proposerConfirmed bool, now time.Time) (claimResult0 *PendingChange, claimResult1 error) { return s.ProposeChangeContext(context.Background(), accountID, app, kind, payload, quorum, proposer, proposerConfirmed, now) } func (s *Store) ProposeChangeContext(claimContext context.Context, accountID int64, app, kind string, payload []byte, quorum int, proposer string, proposerConfirmed bool, now time.Time) (claimResult0 *PendingChange, claimResult1 error) { claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.ProposeChange", func(claimView *Store) error { claimResult0, claimResult1 = claimView.claimRawProposeChange(accountID, app, kind, payload, quorum, proposer, proposerConfirmed, now) return claimResult1 }) return }