| new file mode 100644 |
| index 000000000..66dc5b326 |
| --- /dev/null |
| +++ b/internal/console/pending.go |
| @@ -0,0 +1,335 @@ |
| +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 ( |
| + "crypto/rand" |
| + "database/sql" |
| + "encoding/hex" |
| + "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 |
| + |
| +// 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) ProposeChange(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("console: no such pending change %q", id) |
| + } |
| + if now.Unix() >= pc.ExpiresAt.Unix() { |
| + return nil, false, fmt.Errorf("console: pending change %q has expired", id) |
| + } |
| + if !executedAtZero(tx, id) { |
| + return nil, false, fmt.Errorf("console: pending change %q has already been executed", 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) PendingChangesFor(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 |
| +} |
| + |
| +// 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() |
| +} |