carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
18import (
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.
30const 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.
41var (
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.
48type 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).
65func (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.
96func 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.
110func (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)
124VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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.
161func (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.
196func (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
203FROM pending_changes
204WHERE account_id = ? AND app = ? AND executed_at = 0 AND expires_at > ?
205ORDER 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.
244func (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.
288func (s *Store) MemberConfirms(accountID int64, id string) ([]string, error) {
289 rows, err := s.db.Query(`SELECT c.address FROM pending_confirms c
290JOIN memberships m ON m.address = c.address AND m.account_id = ?
291WHERE 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.
309func (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.
317func (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.
339func 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
343FROM 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.
360func 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.
370func 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}
386func (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.
394func (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}
397func (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}
400func (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