| 1 | package console |
| 2 | |
| 3 | // Pending changes: multi-party quorum for the actions release-pipelines-v2 |
| 4 | // gates (spec §4) — promote, policy, fasttrack. A change is proposed once, |
| 5 | // with its payload pinned at propose time (canonical JSON — Task 8 re-checks |
| 6 | // nothing here, it only ever executes the payload this row already holds), |
| 7 | // and members confirm it until a quorum is met. |
| 8 | // |
| 9 | // The load-bearing rule (spec §4, controller ruling): a BEARER TOKEN NEVER |
| 10 | // COUNTS TOWARD A PASSKEY QUORUM. A console/step-up proposal has already |
| 11 | // proved presence, so its proposer is recorded as the first confirmation in |
| 12 | // the same transaction that creates the row. A CLI bearer-token proposal has |
| 13 | // proved nothing of the kind, so it starts at zero confirmations — even a |
| 14 | // quorum of 1 needs a SEPARATE, passkey-backed confirmation before it is |
| 15 | // met, including one made by the same identity that proposed it over the |
| 16 | // CLI. |
| 17 | |
| 18 | import ( |
| 19 | "context" |
| 20 | "crypto/rand" |
| 21 | "database/sql" |
| 22 | "encoding/hex" |
| 23 | "errors" |
| 24 | "fmt" |
| 25 | "time" |
| 26 | ) |
| 27 | |
| 28 | // PendingChangeTTL bounds how long a proposed change waits for quorum before |
| 29 | // it can no longer be confirmed or executed. |
| 30 | const PendingChangeTTL = 7 * 24 * time.Hour |
| 31 | |
| 32 | // ConfirmChange's three refusals, as values. The console maps them onto the |
| 33 | // wire (refuseConfirm: 404 / 410 / 409), and it does so with errors.Is — the |
| 34 | // substring matching this replaced meant a reworded message silently became a |
| 35 | // 500 telling a member to "try again" about a change that can never succeed. |
| 36 | // A refusal that has to be recognised by another package's grep is not a |
| 37 | // contract; these are. |
| 38 | // |
| 39 | // Each is wrapped with the change's id at the raise site, so the log and the |
| 40 | // operator keep the detail while callers keep the value. |
| 41 | var ( |
| 42 | ErrNoPendingChange = errors.New("console: no such pending change") |
| 43 | ErrPendingChangeExpired = errors.New("console: pending change has expired") |
| 44 | ErrPendingChangeExecuted = errors.New("console: pending change has already been executed") |
| 45 | ) |
| 46 | |
| 47 | // PendingChange is one proposed, quorum-gated action. |
| 48 | type PendingChange struct { |
| 49 | ID string |
| 50 | AccountID int64 |
| 51 | App string |
| 52 | Kind string // "promote" | "policy" | "fasttrack" |
| 53 | Payload []byte // canonical JSON, pinned at propose time |
| 54 | Quorum int |
| 55 | Proposer string |
| 56 | CreatedAt time.Time |
| 57 | ExpiresAt time.Time |
| 58 | Confirms []string // addresses, proposer included when passkey-backed |
| 59 | } |
| 60 | |
| 61 | // migratePendingChanges adds this feature's two tables. Additive and |
| 62 | // idempotent, and kept here rather than in store.go's list so the feature is |
| 63 | // one file to read and one file to remove (the authstore.go / stepup.go |
| 64 | // precedent). |
| 65 | func (s *Store) migratePendingChanges() error { |
| 66 | for _, stmt := range []string{ |
| 67 | `CREATE TABLE IF NOT EXISTS pending_changes ( |
| 68 | id TEXT PRIMARY KEY, |
| 69 | account_id INTEGER NOT NULL, |
| 70 | app TEXT NOT NULL, |
| 71 | kind TEXT NOT NULL, |
| 72 | payload TEXT NOT NULL, |
| 73 | quorum INTEGER NOT NULL, |
| 74 | proposer TEXT NOT NULL, |
| 75 | created_at INTEGER NOT NULL, |
| 76 | expires_at INTEGER NOT NULL, |
| 77 | executed_at INTEGER NOT NULL DEFAULT 0 |
| 78 | );`, |
| 79 | `CREATE TABLE IF NOT EXISTS pending_confirms ( |
| 80 | change_id TEXT NOT NULL, |
| 81 | address TEXT NOT NULL, |
| 82 | confirmed_at INTEGER NOT NULL, |
| 83 | PRIMARY KEY (change_id, address) |
| 84 | );`, |
| 85 | } { |
| 86 | if _, err := s.db.Exec(stmt); err != nil { |
| 87 | return err |
| 88 | } |
| 89 | } |
| 90 | return nil |
| 91 | } |
| 92 | |
| 93 | // newChangeID mints a pending-change id: 16 bytes of crypto/rand, hex — |
| 94 | // the same shape as the keymail envelope id (keymail.go), unguessable and |
| 95 | // URL-safe with no encoding fuss. |
| 96 | func newChangeID() (string, error) { |
| 97 | b := make([]byte, 16) |
| 98 | if _, err := rand.Read(b); err != nil { |
| 99 | return "", err |
| 100 | } |
| 101 | return hex.EncodeToString(b), nil |
| 102 | } |
| 103 | |
| 104 | // ProposeChange inserts a change; proposerConfirmed records the proposer's |
| 105 | // confirmation row (true only for console/step-up proposals — spec §4: a |
| 106 | // bearer token never counts toward a passkey quorum). One transaction: a |
| 107 | // console/step-up proposal's row and its first confirmation must never be |
| 108 | // separable by a crash, or a reader could observe a change credited to |
| 109 | // nobody despite the proposer having just proved presence for it. |
| 110 | func (s *Store) claimRawProposeChange(accountID int64, app, kind string, payload []byte, quorum int, proposer string, proposerConfirmed bool, now time.Time) (*PendingChange, error) { |
| 111 | id, err := newChangeID() |
| 112 | if err != nil { |
| 113 | return nil, err |
| 114 | } |
| 115 | created := now.Unix() |
| 116 | expires := now.Add(PendingChangeTTL).Unix() |
| 117 | tx, err := s.db.Begin() |
| 118 | if err != nil { |
| 119 | return nil, err |
| 120 | } |
| 121 | defer tx.Rollback() |
| 122 | if _, err := tx.Exec(`INSERT INTO pending_changes |
| 123 | (id, account_id, app, kind, payload, quorum, proposer, created_at, expires_at) |
| 124 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
| 125 | id, accountID, app, kind, payload, quorum, proposer, created, expires); err != nil { |
| 126 | return nil, err |
| 127 | } |
| 128 | var confirms []string |
| 129 | if proposerConfirmed { |
| 130 | if _, err := tx.Exec(`INSERT INTO pending_confirms (change_id, address, confirmed_at) VALUES (?, ?, ?)`, |
| 131 | id, proposer, created); err != nil { |
| 132 | return nil, err |
| 133 | } |
| 134 | confirms = []string{proposer} |
| 135 | } |
| 136 | if err := tx.Commit(); err != nil { |
| 137 | return nil, err |
| 138 | } |
| 139 | return &PendingChange{ |
| 140 | ID: id, |
| 141 | AccountID: accountID, |
| 142 | App: app, |
| 143 | Kind: kind, |
| 144 | Payload: payload, |
| 145 | Quorum: quorum, |
| 146 | Proposer: proposer, |
| 147 | CreatedAt: time.Unix(created, 0).UTC(), |
| 148 | ExpiresAt: time.Unix(expires, 0).UTC(), |
| 149 | Confirms: confirms, |
| 150 | }, nil |
| 151 | } |
| 152 | |
| 153 | // ConfirmChange records one member's confirmation (idempotent per address — |
| 154 | // INSERT OR IGNORE, then a recount, so confirming twice never inflates the |
| 155 | // tally) and reports whether quorum is now met, including when it was |
| 156 | // already met before this call. Expired (now >= ExpiresAt) or executed |
| 157 | // changes refuse outright: neither state accepts a new confirmation. One |
| 158 | // transaction throughout, so the read that decides "expired"/"executed" and |
| 159 | // the write that records the confirmation can never straddle a concurrent |
| 160 | // MarkExecuted or a second confirmation. |
| 161 | func (s *Store) ConfirmChange(id, address string, now time.Time) (*PendingChange, bool, error) { |
| 162 | tx, err := s.db.Begin() |
| 163 | if err != nil { |
| 164 | return nil, false, err |
| 165 | } |
| 166 | defer tx.Rollback() |
| 167 | pc, err := loadPendingChange(tx, id) |
| 168 | if err != nil { |
| 169 | return nil, false, err |
| 170 | } |
| 171 | if pc == nil { |
| 172 | return nil, false, fmt.Errorf("%w: %q", ErrNoPendingChange, id) |
| 173 | } |
| 174 | if now.Unix() >= pc.ExpiresAt.Unix() { |
| 175 | return nil, false, fmt.Errorf("%w: %q", ErrPendingChangeExpired, id) |
| 176 | } |
| 177 | if !executedAtZero(tx, id) { |
| 178 | return nil, false, fmt.Errorf("%w: %q", ErrPendingChangeExecuted, id) |
| 179 | } |
| 180 | if _, err := tx.Exec(`INSERT OR IGNORE INTO pending_confirms (change_id, address, confirmed_at) VALUES (?, ?, ?)`, |
| 181 | id, address, now.Unix()); err != nil { |
| 182 | return nil, false, err |
| 183 | } |
| 184 | confirms, err := confirmsFor(tx, id) |
| 185 | if err != nil { |
| 186 | return nil, false, err |
| 187 | } |
| 188 | pc.Confirms = confirms |
| 189 | if err := tx.Commit(); err != nil { |
| 190 | return nil, false, err |
| 191 | } |
| 192 | return pc, len(confirms) >= pc.Quorum, nil |
| 193 | } |
| 194 | |
| 195 | // PendingChangesFor lists an account/app's unexpired, unexecuted changes. |
| 196 | func (s *Store) claimRawPendingChangesFor(accountID int64, app string, now time.Time) ([]PendingChange, error) { |
| 197 | tx, err := s.db.Begin() |
| 198 | if err != nil { |
| 199 | return nil, err |
| 200 | } |
| 201 | defer tx.Rollback() |
| 202 | rows, err := tx.Query(`SELECT id, account_id, app, kind, payload, quorum, proposer, created_at, expires_at |
| 203 | FROM pending_changes |
| 204 | WHERE account_id = ? AND app = ? AND executed_at = 0 AND expires_at > ? |
| 205 | ORDER BY created_at, id`, accountID, app, now.Unix()) |
| 206 | if err != nil { |
| 207 | return nil, err |
| 208 | } |
| 209 | var ids []string |
| 210 | var out []PendingChange |
| 211 | for rows.Next() { |
| 212 | var pc PendingChange |
| 213 | var created, expires int64 |
| 214 | if err := rows.Scan(&pc.ID, &pc.AccountID, &pc.App, &pc.Kind, &pc.Payload, &pc.Quorum, &pc.Proposer, |
| 215 | &created, &expires); err != nil { |
| 216 | rows.Close() |
| 217 | return nil, err |
| 218 | } |
| 219 | pc.CreatedAt = time.Unix(created, 0).UTC() |
| 220 | pc.ExpiresAt = time.Unix(expires, 0).UTC() |
| 221 | out = append(out, pc) |
| 222 | ids = append(ids, pc.ID) |
| 223 | } |
| 224 | rows.Close() |
| 225 | if err := rows.Err(); err != nil { |
| 226 | return nil, err |
| 227 | } |
| 228 | for i, id := range ids { |
| 229 | confirms, err := confirmsFor(tx, id) |
| 230 | if err != nil { |
| 231 | return nil, err |
| 232 | } |
| 233 | out[i].Confirms = confirms |
| 234 | } |
| 235 | if err := tx.Commit(); err != nil { |
| 236 | return nil, err |
| 237 | } |
| 238 | return out, nil |
| 239 | } |
| 240 | |
| 241 | // PendingChange fetches one change, excluding executed and expired rows — |
| 242 | // both read as (nil, nil), the same "not found" a caller gets for an id that |
| 243 | // was never proposed at all. |
| 244 | func (s *Store) PendingChange(id string, now time.Time) (*PendingChange, error) { |
| 245 | tx, err := s.db.Begin() |
| 246 | if err != nil { |
| 247 | return nil, err |
| 248 | } |
| 249 | defer tx.Rollback() |
| 250 | pc, err := loadPendingChange(tx, id) |
| 251 | if err != nil { |
| 252 | return nil, err |
| 253 | } |
| 254 | if pc == nil { |
| 255 | return nil, nil |
| 256 | } |
| 257 | if now.Unix() >= pc.ExpiresAt.Unix() { |
| 258 | return nil, nil |
| 259 | } |
| 260 | if !executedAtZero(tx, id) { |
| 261 | return nil, nil |
| 262 | } |
| 263 | confirms, err := confirmsFor(tx, id) |
| 264 | if err != nil { |
| 265 | return nil, err |
| 266 | } |
| 267 | pc.Confirms = confirms |
| 268 | if err := tx.Commit(); err != nil { |
| 269 | return nil, err |
| 270 | } |
| 271 | return pc, nil |
| 272 | } |
| 273 | |
| 274 | // MemberConfirms is a change's confirming addresses filtered to those that |
| 275 | // are STILL members of the account — the tally EXECUTION must count (spec §4: |
| 276 | // "quorum is N distinct MEMBERS"). A confirmation is an act of standing |
| 277 | // authority, and an address that has left the account has none: without this |
| 278 | // filter, removing somebody from an account would leave their approvals |
| 279 | // working on their way out, and an account could be brought to quorum by |
| 280 | // people who are no longer in it. |
| 281 | // |
| 282 | // The rows are KEPT, not deleted. Who confirmed what is a true fact about the |
| 283 | // past and the trail says so; what changes is only whether it still counts. |
| 284 | // Confirms (the display tally on the card and the CLI list) deliberately |
| 285 | // keeps showing every row — the gap between the two numbers is the honest |
| 286 | // picture, and a card that quietly dropped a name would make a stuck change |
| 287 | // unexplainable. |
| 288 | func (s *Store) MemberConfirms(accountID int64, id string) ([]string, error) { |
| 289 | rows, err := s.db.Query(`SELECT c.address FROM pending_confirms c |
| 290 | JOIN memberships m ON m.address = c.address AND m.account_id = ? |
| 291 | WHERE c.change_id = ? ORDER BY c.confirmed_at, c.address`, accountID, id) |
| 292 | if err != nil { |
| 293 | return nil, err |
| 294 | } |
| 295 | defer rows.Close() |
| 296 | var out []string |
| 297 | for rows.Next() { |
| 298 | var a string |
| 299 | if err := rows.Scan(&a); err != nil { |
| 300 | return nil, err |
| 301 | } |
| 302 | out = append(out, a) |
| 303 | } |
| 304 | return out, rows.Err() |
| 305 | } |
| 306 | |
| 307 | // MarkExecuted stamps a change as carried out, so it stops appearing as |
| 308 | // pending and can no longer be confirmed. |
| 309 | func (s *Store) MarkExecuted(id string, now time.Time) error { |
| 310 | _, err := s.db.Exec(`UPDATE pending_changes SET executed_at = ? WHERE id = ? AND executed_at = 0`, |
| 311 | now.Unix(), id) |
| 312 | return err |
| 313 | } |
| 314 | |
| 315 | // DeleteChange removes a change outright — reject/cancel. Its confirmations |
| 316 | // go with it: a deleted change is gone, not merely emptied. |
| 317 | func (s *Store) DeleteChange(id string) error { |
| 318 | tx, err := s.db.Begin() |
| 319 | if err != nil { |
| 320 | return err |
| 321 | } |
| 322 | defer tx.Rollback() |
| 323 | if _, err := tx.Exec(`DELETE FROM pending_confirms WHERE change_id = ?`, id); err != nil { |
| 324 | return err |
| 325 | } |
| 326 | if _, err := tx.Exec(`DELETE FROM pending_changes WHERE id = ?`, id); err != nil { |
| 327 | return err |
| 328 | } |
| 329 | return tx.Commit() |
| 330 | } |
| 331 | |
| 332 | // ---- helpers ---------------------------------------------------------------- |
| 333 | |
| 334 | // loadPendingChange reads a change row by id, unfiltered by expiry or |
| 335 | // execution — the callers above apply those rules themselves, since |
| 336 | // ConfirmChange needs to distinguish "expired" from "executed" for its error |
| 337 | // message and PendingChange/PendingChangesFor need to treat both as absence. |
| 338 | // Confirms is left nil; callers fill it via confirmsFor. |
| 339 | func loadPendingChange(tx *sql.Tx, id string) (*PendingChange, error) { |
| 340 | var pc PendingChange |
| 341 | var created, expires int64 |
| 342 | err := tx.QueryRow(`SELECT id, account_id, app, kind, payload, quorum, proposer, created_at, expires_at |
| 343 | FROM pending_changes WHERE id = ?`, id). |
| 344 | Scan(&pc.ID, &pc.AccountID, &pc.App, &pc.Kind, &pc.Payload, &pc.Quorum, &pc.Proposer, &created, &expires) |
| 345 | if err == sql.ErrNoRows { |
| 346 | return nil, nil |
| 347 | } |
| 348 | if err != nil { |
| 349 | return nil, err |
| 350 | } |
| 351 | pc.CreatedAt = time.Unix(created, 0).UTC() |
| 352 | pc.ExpiresAt = time.Unix(expires, 0).UTC() |
| 353 | return &pc, nil |
| 354 | } |
| 355 | |
| 356 | // executedAtZero reports whether a change's executed_at is still 0 (i.e. it |
| 357 | // has not been executed). A missing row reads as "not executed" — the |
| 358 | // caller has already confirmed the row exists via loadPendingChange before |
| 359 | // this is asked. |
| 360 | func executedAtZero(tx *sql.Tx, id string) bool { |
| 361 | var executedAt int64 |
| 362 | if err := tx.QueryRow(`SELECT executed_at FROM pending_changes WHERE id = ?`, id).Scan(&executedAt); err != nil { |
| 363 | return true // let the caller's own row-existence check own "not found" |
| 364 | } |
| 365 | return executedAt == 0 |
| 366 | } |
| 367 | |
| 368 | // confirmsFor returns a change's confirming addresses, sorted by when they |
| 369 | // confirmed. |
| 370 | func confirmsFor(tx *sql.Tx, id string) ([]string, error) { |
| 371 | rows, err := tx.Query(`SELECT address FROM pending_confirms WHERE change_id = ? ORDER BY confirmed_at, address`, id) |
| 372 | if err != nil { |
| 373 | return nil, err |
| 374 | } |
| 375 | defer rows.Close() |
| 376 | var out []string |
| 377 | for rows.Next() { |
| 378 | var a string |
| 379 | if err := rows.Scan(&a); err != nil { |
| 380 | return nil, err |
| 381 | } |
| 382 | out = append(out, a) |
| 383 | } |
| 384 | return out, rows.Err() |
| 385 | } |
| 386 | func (s *Store) PendingChangesFor(accountID int64, app string, now time.Time) (claimResult0 []PendingChange, claimResult1 error) { |
| 387 | return s.PendingChangesForContext(context.Background(), accountID, app, now) |
| 388 | } |
| 389 | |
| 390 | // PendingChangesForContext is a read: its transaction only SELECTs, so it |
| 391 | // takes no app mutation. Taking one would make every page and CLI list that |
| 392 | // shows the pending card register with the gate and refuse under a foreign |
| 393 | // operation. The armed triggers still refuse any write it could ever grow. |
| 394 | func (s *Store) PendingChangesForContext(claimContext context.Context, accountID int64, app string, now time.Time) (claimResult0 []PendingChange, claimResult1 error) { |
| 395 | return s.claimRawPendingChangesFor(accountID, app, now) |
| 396 | } |
| 397 | func (s *Store) ProposeChange(accountID int64, app, kind string, payload []byte, quorum int, proposer string, proposerConfirmed bool, now time.Time) (claimResult0 *PendingChange, claimResult1 error) { |
| 398 | return s.ProposeChangeContext(context.Background(), accountID, app, kind, payload, quorum, proposer, proposerConfirmed, now) |
| 399 | } |
| 400 | 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) { |
| 401 | claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.ProposeChange", func(claimView *Store) error { |
| 402 | claimResult0, claimResult1 = claimView.claimRawProposeChange(accountID, app, kind, payload, quorum, proposer, proposerConfirmed, now) |
| 403 | return claimResult1 |
| 404 | }) |
| 405 | return |
| 406 | } |
| 407 | |