Files
stack/agents/sage/work/setspark-approvers/fix.patch
T
jason.woltjeandClaude Opus 5.5 f87cd6e201 docs(records): SetSpark approver fix landed in shared-signals cc74d92, lead decision 24
Packet, Rocko's R1 and R2 reviews, DEFERRED outcome and SESSIONS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 18:52:04 -05:00

417 lines
34 KiB
Diff

diff --git a/stack/api/README.md b/stack/api/README.md
index 475fb5f..d23ba36 100644
--- a/stack/api/README.md
+++ b/stack/api/README.md
@@ -74,7 +74,7 @@ verified key id; it is never used for authorization.
| Verb | REST | MCP tool | Notes |
|---|---|---|---|
-| create | `POST /v1/records` `{record_type, record}` | `create` | Allocates the next ID from `counters`; work items default Open/Later; decisions start Proposed, version 1 |
+| create | `POST /v1/records` `{record_type, record}` | `create` | Allocates the next ID from `counters`; work items default Open/Later; decisions start Proposed, version 1. Decisions must satisfy the required approvers rule below |
| update | `PATCH /v1/records/{id}` `{revision, fields}` | `update` | Revision CAS. Mismatch: 409 with `current_revision` and `changed_fields` (from audit). Digest-covered edits to a Proposed decision bump `proposal_version` |
| import | `POST /v1/import` | `import` | Privileged. Keeps the source `id`, moves the counter past it, skips link existence checks unless `check_links` is true. Accepted/Superseded sources must carry reproducible approvals |
| resolve_links | `POST /v1/import/resolve-links` `{items: [{record_type, id, links}]}` | `resolve_links` | Migration link pass, only for records still marked `import_pending` (set by `import`, cleared by `finalize_import`); anything else is `links_locked`. Sets `business`, `project`, `work_item`, `supersedes`, `superseded_by`, `related` in one transaction, verifying targets exist and cardinality (`related` is a list of ids, every other link a single id), then type-checks the resulting record, without bumping `revision`. One bad link fails the batch. Decisions with any approval, an `accepted_snapshot`, or status Accepted/Superseded are refused (`links_locked`). A link change on a Proposed decision recomputes `proposal_digest` (no version bump: no approval can exist yet) |
@@ -83,11 +83,11 @@ verified key id; it is never used for authorization.
| list | `GET /v1/records?record_type=..&<prop>=..` (`type=` also accepted) | `list` | Equality filters on column-backed properties, `limit` (max 500), `offset` |
| resolve_id | `GET /v1/resolve?q=` | `resolve_id` | Exact ID or title substring |
| get_counters | `GET /v1/counters` | `get_counters` | Read verb: `{counters: {prefix: next}}` for every ID prefix, for the migration verify step against the frozen registry |
-| open_approval_request | `POST /v1/approval-requests` `{decision_id, proposal_version, proposal_digest}` | same | Model verb, before any Discord message exists. Version and digest must be current (else `request_stale`). Returns `request_id`, `required_approvers` as Discord user ids, `state: open` |
+| open_approval_request | `POST /v1/approval-requests` `{decision_id, proposal_version, proposal_digest}` | same | Model verb, before any Discord message exists. Version and digest must be current (else `request_stale`). Refused with `approvers_not_ready` unless the stored list is ready: 1 to 16 distinct `discord:<user id>` entries (replace pending markers or legacy entries through `update` first). Returns `request_id`, `required_approvers` as Discord user ids, `state: open` |
| bind_approval_message | `POST /v1/approval-requests/{request_id}/message` `{message_id, channel_id}` | same | Connector only (sage). Key `<principal>:<message id>:bind`; replay returns the same result; `message_id` unique; rebinding is `already_bound` |
-| add_approval | `POST /v1/approvals` `{request_id, kind, author_id, message_id, source_url, statement, bound_message_id?, at?}` | same | Connector only (sage). Key `<principal>:<event id>:approval`. `kind` is `button` or `reply`. A button event must have `message_id` equal to the bound message (`bound_message_id` is ignored). A reply event must carry its own `message_id`, `bound_message_id` equal to the bound message, and a `statement` that trimmed and case-folded is exactly `approve` (`not_affirmative`). Acceptance also verifies the decision's supersession links: it must not carry `superseded_by`, and any `supersedes` must point at an Accepted decision not superseded by anyone else (`invalid_supersession`). Checks in order: request open (`not_open`); decision still Proposed at the request's version and digest, otherwise the request is closed and `request_stale` returned; author in required approvers (`not_approver`); not already approved (`already_approved`); `message_id` is the bound message or `bound_message_id` equals it (`message_mismatch`); `source_url` unique. Acceptance, `accepted_snapshot`, request `state: approved` and reciprocal supersession all commit with the completing approval |
+| add_approval | `POST /v1/approvals` `{request_id, kind, author_id, message_id, source_url, statement, bound_message_id?, at?}` | same | Connector only (sage). Key `<principal>:<event id>:approval`. `kind` is `button` or `reply`. A button event must have `message_id` equal to the bound message (`bound_message_id` is ignored). A reply event must carry its own `message_id`, `bound_message_id` equal to the bound message, and a `statement` that trimmed and case-folded is exactly `approve` (`not_affirmative`). Acceptance also verifies the decision's supersession links: it must not carry `superseded_by`, and any `supersedes` must point at an Accepted decision not superseded by anyone else (`invalid_supersession`). Checks in order: request open (`not_open`); decision still Proposed at the request's version and digest, otherwise the request is closed and `request_stale` returned; both the request's stored approvers and the decision's are ready (`approvers_not_ready`, nothing written); author in required approvers (`not_approver`); not already approved (`already_approved`); `message_id` is the bound message or `bound_message_id` equals it (`message_mismatch`); `source_url` unique. Acceptance, `accepted_snapshot`, request `state: approved` and reciprocal supersession all commit with the completing approval |
| get_approval_request | `GET /v1/approval-requests/{request_id}` | same | `state` open/approved/closed, bound `channel_id`/`message_id`, `approvals` (approver, at, message_id, source_url) |
-| supersede | `POST /v1/supersede` `{decision_id, successor_id}` | same | Successor must be Accepted and created with `supersedes = decision_id`; marks the old decision Superseded with `superseded_by`; acyclic. The predecessor must be Accepted (`predecessor_not_accepted`), checked both when the successor is created with `supersedes` and again when it is accepted |
+| supersede | `POST /v1/supersede` `{decision_id, successor_id}` | same | Successor must be Accepted and created with `supersedes = decision_id`; marks the old decision Superseded with `superseded_by`; acyclic. An old decision whose stored approvers break the rule for Superseded is refused (`invalid_record`), here and when a successor's acceptance would supersede it. The predecessor must be Accepted (`predecessor_not_accepted`), checked both when the successor is created with `supersedes` and again when it is accepted |
| create_document | `POST /v1/documents` `{collection, title, text, source?}` | same | Outline, allowlisted collections only. `source` must be one non-empty line: any character `str.splitlines` splits on is refused (`invalid_source`). The `documents.search` reconciliation is paginated to exhaustion before a document is considered absent; a failing page raises `outline_error` and keeps the pending row. One connection holds a per-key session advisory lock across the pending row, the search by `Source:` header line, the create and the receipt, so concurrent calls with the same key serialise and never both reach Outline; replay reconciles instead of creating twice |
Records are exchanged in frontmatter shape (`title`, `business`, `work_item`, `proposal_body`,
@@ -106,6 +106,73 @@ acyclicity, referenced records must exist (create/update). The database adds ins
triggers on `approvals` and `audit`, and a trigger that refuses any change to digest-covered
columns or the snapshot of an Accepted or Superseded decision.
+### Required approvers
+
+A decision's `required_approvers` is a list of 1 to 16 distinct strings. Each entry is either
+`discord:<user id>` (17 to 22 ASCII digits, the same pattern as `tools/validate_vault.py`) or
+`pending:<text>` whose text is not empty after Python `str.strip()`. Pending markers follow the
+records convention as the validator applies it: allowed on Proposed and Rejected decisions,
+refused on Accepted and Superseded ones. One helper (`service.check_required_approvers`)
+enforces this on create, update, import and finalize_import. Update checks the merged record,
+so an update to a stored decision that breaks the rule is refused until the same update also
+corrects the list. A failure is 422 `invalid_record`; the message states the rule and never
+echoes the value.
+
+Stored rows are rechecked wherever approvals are collected or a decision is sealed, because rows
+and approval requests written before this rule can hold any list. `open_approval_request` and
+`add_approval` refuse with `approvers_not_ready` unless the list is ready: the rule in its
+Accepted form, so 1 to 16 distinct Discord ids and no pending markers. `add_approval` checks both
+the request's copy and the decision's list before writing anything, which also guards the seal
+to Accepted. Superseding a decision (`supersede`, or the acceptance of a successor that
+`supersedes` it) is refused with `invalid_record` when the old decision's stored list breaks the
+rule for Superseded.
+
+Rows written before this rule may break it; DEC-009's bare names are the known case. No
+migration rewrites them. A Proposed or Rejected row is corrected through `update`. An Accepted
+row is frozen by the immutability trigger, so one with a broken list cannot be corrected through
+the API and cannot be superseded; the first query lists any such row for an owner decision.
+These read-only queries list the affected rows:
+
+```sql
+-- decisions whose required_approvers break the rule
+SELECT d.id, d.status, d.proposal_version
+FROM setspark.decisions d
+WHERE CASE
+ WHEN jsonb_typeof(d.required_approvers) <> 'array' THEN true
+ WHEN jsonb_array_length(d.required_approvers) NOT BETWEEN 1 AND 16 THEN true
+ ELSE EXISTS (SELECT 1 FROM jsonb_array_elements(d.required_approvers) AS e(v)
+ WHERE jsonb_typeof(e.v) <> 'string'
+ OR NOT ((e.v #>> '{}') ~ '^discord:[0-9]{17,22}$'
+ OR (d.status NOT IN ('Accepted', 'Superseded')
+ AND (e.v #>> '{}') ~ '^pending:.*[^\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]')))
+ OR (SELECT count(DISTINCT e.v) FROM jsonb_array_elements(d.required_approvers) AS e(v))
+ <> jsonb_array_length(d.required_approvers)
+ END
+ORDER BY d.id;
+
+-- open approval requests whose approvers are not ready (not 1 to 16 distinct Discord ids)
+SELECT r.id, r.decision_id, r.proposal_version
+FROM setspark.approval_requests r
+WHERE r.state = 'open'
+ AND CASE
+ WHEN jsonb_typeof(r.required_approvers) <> 'array' THEN true
+ WHEN jsonb_array_length(r.required_approvers) NOT BETWEEN 1 AND 16 THEN true
+ ELSE EXISTS (SELECT 1 FROM jsonb_array_elements(r.required_approvers) AS e(v)
+ WHERE jsonb_typeof(e.v) <> 'string'
+ OR (e.v #>> '{}') !~ '^discord:[0-9]{17,22}$')
+ OR (SELECT count(DISTINCT e.v) FROM jsonb_array_elements(r.required_approvers) AS e(v))
+ <> jsonb_array_length(r.required_approvers)
+ END
+ORDER BY r.id;
+```
+
+The pending test in the first query spells out the characters Python `str.strip()` removes
+instead of using `[:space:]`, whose meaning depends on the database locale. The second query
+lists every open request that `add_approval` now refuses, which includes requests holding
+pending markers, entries of neither form, duplicates, and more than 16 entries. Correcting the
+decision's approvers through `update` bumps `proposal_version` (the list is digest-covered), so
+the next `add_approval` against the old request closes it with `request_stale`.
+
Digest: SHA-256 over canonical JSON `{properties, body}` (sorted keys, compact separators,
Unicode preserved), properties excluding `status`, `approvals`, `updated`, `superseded_by` and
every database-added column; body is the stripped Markdown. `setspark_api/digest.py` is a copy
@@ -149,6 +216,7 @@ Every failure body is exactly `{code, message}`; 409 `stale_revision` adds `curr
| 422 | idempotency_mismatch | Same key, different request |
| 422 | invalid_record, invalid_link, invalid_filter, invalid_source | Validation. Records are type-checked (pydantic, per record type, unknown properties allowed) before any SQL; messages name fields and error kinds only |
| 422 | import_pending | open_approval_request or add_approval on a decision whose import is not finalized |
+| 422 | approvers_not_ready | open_approval_request or add_approval while the stored required approvers (the decision's, or the request's copy) are not 1 to 16 distinct `discord:<user id>` entries, for example a pending marker or a list stored before the rule; nothing is written and no value is echoed |
| 422 | reserved_property | A caller supplied a service-owned flag (`import_pending`) in a record or update fields |
| 422 | links_locked | resolve_links on a record that is not `import_pending`, or on a decision that already has approval evidence |
| 422 | invalid_supersession | At acceptance: the decision carries `superseded_by`, or `supersedes` is not an Accepted decision free to be superseded. At finalize_import: a supersession link target is missing, not reciprocal, of the wrong status, or forms a cycle; the record stays `import_pending` |
diff --git a/stack/api/setspark_api/service.py b/stack/api/setspark_api/service.py
index 5fc1152..9d8aacd 100644
--- a/stack/api/setspark_api/service.py
+++ b/stack/api/setspark_api/service.py
@@ -153,6 +153,50 @@ def evidence(v):
return isinstance(v, list) and bool(v) and all(nonempty(x) for x in v)
+APPROVERS_MAX = 16
+PENDING_PREFIX = 'pending:'
+SEALED_STATUS = ('Accepted', 'Superseded')
+APPROVERS_RULE = ('required_approvers must be a list of 1 to %d distinct entries, each discord:<user id> '
+ '(17 to 22 ASCII digits) or pending:<nonempty text>' % APPROVERS_MAX)
+
+
+def is_discord_approver(v):
+ return isinstance(v, str) and DISCORD_ID_RE.fullmatch(v) is not None
+
+
+def is_pending_approver(v):
+ return isinstance(v, str) and v.startswith(PENDING_PREFIX) and nonempty(v[len(PENDING_PREFIX):])
+
+
+def check_required_approvers(approvers, status):
+ """The single required_approvers rule, applied wherever a decision is validated (create, update, import,
+ finalize_import). Pending markers follow tools/validate_vault.py: allowed until the decision is Accepted or
+ Superseded, which need stable Discord IDs. Messages state the rule and never echo the submitted values."""
+ if not isinstance(approvers, list) or not 1 <= len(approvers) <= APPROVERS_MAX:
+ raise ApiError(422, 'invalid_record', APPROVERS_RULE)
+ if not all(is_discord_approver(x) or is_pending_approver(x) for x in approvers):
+ raise ApiError(422, 'invalid_record', APPROVERS_RULE)
+ if len(set(approvers)) != len(approvers):
+ raise ApiError(422, 'invalid_record', 'required_approvers must be distinct')
+ if status in SEALED_STATUS and not all(is_discord_approver(x) for x in approvers):
+ raise ApiError(422, 'invalid_record', 'Accepted and Superseded decisions require discord:<user id> approvers, not pending markers')
+
+
+def approvers_ready(approvers):
+ """True when a stored list may collect approvals or be sealed: the full rule in its Accepted/Superseded form (1 to 16
+ distinct discord:<user id> entries, no pending markers). Stored rows are rechecked because rows written before
+ this rule, and approval requests copied from them, can hold any list."""
+ try:
+ check_required_approvers(approvers, SEALED_STATUS[0])
+ except ApiError:
+ return False
+ return True
+
+
+APPROVERS_NOT_READY = ('Every required approver must be a distinct discord:<user id>, 1 to %d of them, before approvals can be '
+ 'collected; correct the decision through update first' % APPROVERS_MAX)
+
+
def jsonable(value):
return json.loads(json.dumps(value, default=str))
@@ -350,8 +394,7 @@ class Service:
raise ApiError(422, 'invalid_record', 'status must be Proposed, Accepted, Rejected or Superseded')
if not nonempty(rec.get('work_item')):
raise ApiError(422, 'invalid_record', 'Decision requires originating work_item')
- if not evidence(rec.get('required_approvers')) or len(set(rec['required_approvers'])) != len(rec['required_approvers']):
- raise ApiError(422, 'invalid_record', 'Decision requires distinct, explicit required_approvers')
+ check_required_approvers(rec.get('required_approvers'), rec['status'])
pv = rec.get('proposal_version', 1)
if type(pv) is not int or pv < 1:
raise ApiError(422, 'invalid_record', 'proposal_version must be a positive integer')
@@ -585,6 +628,8 @@ class Service:
raise ApiError(422, 'import_pending', '%s is an unfinished import; finalize_import first' % row['id'])
if payload.get('proposal_version') != row['proposal_version'] or payload.get('proposal_digest') != row['proposal_digest']:
raise ApiError(422, 'request_stale', 'The decision is at version %d with a different digest; get it again' % row['proposal_version'])
+ if not approvers_ready(row['required_approvers']):
+ raise ApiError(422, 'approvers_not_ready', APPROVERS_NOT_READY)
req = conn.execute('''INSERT INTO approval_requests (decision_id, proposal_version, proposal_digest, required_approvers)
VALUES (%s, %s, %s, %s) RETURNING *''', (row['id'], row['proposal_version'], row['proposal_digest'], Jsonb(row['required_approvers']))).fetchone()
out = self._request_view(conn, req)
@@ -642,6 +687,10 @@ class Service:
raise ApiError(422, 'request_stale', 'Request %d was for version %d; the proposal is now version %d and %s. Request closed.' % (req['id'], req['proposal_version'], row['proposal_version'], row['status']), commit=close)
if row['import_pending']:
raise ApiError(422, 'import_pending', '%s is an unfinished import; finalize_import first' % row['id'])
+ # Before any write: this guards the approval row and the seal to Accepted below. The request's copy and the
+ # decision's list are both checked, since either may predate the rule.
+ if not approvers_ready(req['required_approvers']) or not approvers_ready(row['required_approvers']):
+ raise ApiError(422, 'approvers_not_ready', APPROVERS_NOT_READY)
if approver not in req['required_approvers']:
raise ApiError(422, 'not_approver', 'Author is not one of the required approvers')
if conn.execute('SELECT 1 FROM approvals WHERE request_id = %s AND approver = %s', (req['id'], approver)).fetchone():
@@ -821,6 +870,9 @@ class Service:
cursor = nxt['supersedes_id'] if nxt else None
if old['status'] == 'Superseded' and old['superseded_by_id'] == new_id:
return old
+ if not approvers_ready(old['required_approvers']): # an Accepted row sealed before the rule; the trigger keeps it as is
+ raise ApiError(422, 'invalid_record', '%s has required_approvers that break the rule for a Superseded decision, so it cannot '
+ 'be superseded' % old_id)
before = self._to_record('decision', old)
newold = self._write(conn, 'decision', old_id, {'status': 'Superseded', 'superseded_by_id': new_id})
self._audit(conn, auth, payload, key, 'decision', old_id, newold['revision'], 'supersede', before, self._to_record('decision', newold))
diff --git a/tools/tests/test_setspark_api.py b/tools/tests/test_setspark_api.py
index 0d5e445..4f1e134 100644
--- a/tools/tests/test_setspark_api.py
+++ b/tools/tests/test_setspark_api.py
@@ -895,6 +895,206 @@ class ServiceTests(unittest.TestCase):
self.finalize(pid)
self.assertNotIn('import_pending', self.svc.execute(self.coord, 'get', {'id': pid})[1])
+ # ----- required_approvers rule ------------------------------------------------
+ def assert_invalid_without_echo(self, cm, approvers, code='invalid_record'):
+ self.assertEqual((cm.exception.status, cm.exception.code), (422, code))
+ shown = (cm.exception.message, json.dumps(cm.exception.body(), ensure_ascii=False), repr(cm.exception), str(cm.exception))
+ for v in approvers:
+ token = v.split(':', 1)[-1].strip() if isinstance(v, str) else ''
+ if token:
+ for text in shown:
+ self.assertNotIn(token, text)
+
+ def far_decision_id(self):
+ """An unused DEC id well above the counter, so a later create cannot collide with it."""
+ return 'DEC-%d' % (self.sql("SELECT next FROM counters WHERE prefix = 'DEC'")[0]['next'] + 5000)
+
+ def test_approvers_create_refuses_every_malformed_form_without_echo(self):
+ for label, approvers in BAD_APPROVERS:
+ with self.subTest(case=label):
+ with self.assertRaises(ApiError) as cm:
+ self.svc.execute(self.coord, 'create', {'record_type': 'decision', 'record': {
+ 'title': 'Bad approvers', 'work_item': self.work, 'required_approvers': approvers, 'proposal_body': 'x'}}, self.key())
+ self.assert_invalid_without_echo(cm, approvers)
+
+ def test_approvers_create_accepts_ids_pending_markers_and_bounds(self):
+ for label, approvers in (('one id', [JASON]), ('17 and 22 digit ids', [_discord(17), _discord(22)]),
+ ('pending marker', ['pending:Fixture approver A', JASON]), ('16 entries', [_fake_approver(i) for i in range(16)])):
+ with self.subTest(case=label):
+ self.assertEqual(self.decision(approvers=approvers)['required_approvers'], approvers)
+
+ def test_approvers_update_refused_the_same_way(self):
+ for label, approvers in BAD_APPROVERS:
+ with self.subTest(case=label):
+ dec = self.decision() # one decision per case, so each case is judged on its own
+ with self.assertRaises(ApiError) as cm:
+ self.svc.execute(self.coord, 'update', {'id': dec['id'], 'revision': dec['revision'], 'fields': {'required_approvers': approvers}}, self.key())
+ self.assert_invalid_without_echo(cm, approvers)
+ _, now = self.svc.execute(self.coord, 'get', {'id': dec['id']})
+ self.assertEqual((now['revision'], now['required_approvers']), (dec['revision'], [JASON, CARMEN]))
+ ok = self.decision()
+ _, upd = self.svc.execute(self.coord, 'update', {'id': ok['id'], 'revision': ok['revision'], 'fields': {'required_approvers': [JASON, 'pending:Fixture approver B']}}, self.key())
+ self.assertEqual(upd['proposal_version'], 2)
+
+ def test_approvers_import_refused_the_same_way(self):
+ for label, approvers in BAD_APPROVERS:
+ with self.subTest(case=label):
+ rid = self.far_decision_id() # unused id per case, so each case is judged on its own
+ with self.assertRaises(ApiError) as cm:
+ self.import_decision(rid, 'Proposed', approvers=approvers)
+ self.assert_invalid_without_echo(cm, approvers)
+ with self.assertRaises(ApiError) as cm:
+ self.svc.execute(self.coord, 'get', {'id': rid})
+ self.assertEqual(cm.exception.code, 'not_found')
+ with self.assertRaises(ApiError) as cm: # a pending marker cannot be sealed
+ self.import_decision(self.far_decision_id(), 'Accepted', approvers=[JASON, 'pending:Fixture approver B'])
+ self.assert_invalid_without_echo(cm, ['pending:Fixture approver B'])
+
+ def test_approvers_pending_markers_follow_the_validator_status_rule(self):
+ mixed = [JASON, 'pending:Fixture approver B']
+ for status in ('Proposed', 'Rejected'):
+ service_mod.check_required_approvers(mixed, status)
+ for status in ('Accepted', 'Superseded'):
+ with self.subTest(status=status):
+ with self.assertRaises(ApiError) as cm:
+ service_mod.check_required_approvers(mixed, status)
+ self.assert_invalid_without_echo(cm, mixed)
+ service_mod.check_required_approvers([JASON, CARMEN], status)
+
+ def test_approvers_open_request_refused_until_every_approver_is_a_discord_id(self):
+ dec = self.decision(approvers=[JASON, 'pending:Fixture approver B'])
+ with self.assertRaises(ApiError) as cm:
+ self.svc.execute(self.sage, 'open_approval_request', {'decision_id': dec['id'], 'proposal_version': dec['proposal_version'], 'proposal_digest': dec['proposal_digest']}, self.key())
+ self.assert_invalid_without_echo(cm, [JASON, 'pending:Fixture approver B'], code='approvers_not_ready')
+ self.assertEqual(self.sql('SELECT count(*) AS n FROM approval_requests WHERE decision_id = %s', dec['id'])[0]['n'], 0)
+ self.svc.execute(self.coord, 'update', {'id': dec['id'], 'revision': dec['revision'], 'fields': {'required_approvers': [JASON, CARMEN]}}, self.key())
+ self.assertEqual(self.open_request(dec['id'])['required_approvers'], [JASON_ID, CARMEN_ID])
+
+ def test_approvers_migration_shaped_import_with_pending_markers_still_finalizes(self):
+ from validate_vault import parse
+ pending = ['pending:Fixture approver A verified approval', 'pending:Fixture approver B verified identity and approval']
+ rid = self.far_decision_id()
+ dec = self.import_decision(rid, 'Proposed', approvers=pending, approvals=[]) # vault frontmatter shape, as ApiSink sends it
+ self.assertEqual((dec['required_approvers'], dec['import_pending']), (pending, True))
+ self.assertEqual(self.finalize(rid)['items'][0]['import_pending'], False)
+ with self.assertRaises(ApiError) as cm:
+ self.svc.execute(self.sage, 'open_approval_request', {'decision_id': rid, 'proposal_version': 1, 'proposal_digest': dec['proposal_digest']}, self.key())
+ self.assertEqual(cm.exception.code, 'approvers_not_ready')
+ for path in sorted((ROOT / 'vault' / 'Decisions').glob('DEC-*.md')): # every current vault decision satisfies the rule
+ meta, _ = parse(path.read_text(encoding='utf-8'))
+ service_mod.check_required_approvers(meta['required_approvers'], meta['status'])
+
+ def test_approvers_stored_legacy_row_is_refused_at_finalize_update_and_open(self):
+ rid = self.far_decision_id()
+ dec = self.import_decision(rid, 'Proposed', approvers=['pending:Fixture approver A'])
+ legacy = ['fixture-name-a', 'fixture-name-b'] # the DEC-009 shape: bare names written before this rule
+ self.sql('UPDATE decisions SET required_approvers = %s::jsonb WHERE id = %s', json.dumps(legacy), rid)
+ with self.assertRaises(ApiError) as cm:
+ self.finalize(rid)
+ self.assert_invalid_without_echo(cm, legacy)
+ self.sql('UPDATE decisions SET import_pending = false WHERE id = %s', rid)
+ with self.assertRaises(ApiError) as cm:
+ self.svc.execute(self.sage, 'open_approval_request', {'decision_id': rid, 'proposal_version': 1, 'proposal_digest': dec['proposal_digest']}, self.key())
+ self.assert_invalid_without_echo(cm, legacy, code='approvers_not_ready')
+ with self.assertRaises(ApiError) as cm: # an unrelated edit cannot carry the bad list forward
+ self.svc.execute(self.coord, 'update', {'id': rid, 'revision': 1, 'fields': {'title': 'Renamed legacy'}}, self.key())
+ self.assert_invalid_without_echo(cm, legacy)
+ _, fixed = self.svc.execute(self.coord, 'update', {'id': rid, 'revision': 1, 'fields': {'required_approvers': [JASON, CARMEN]}}, self.key())
+ self.assertEqual((fixed['required_approvers'], fixed['proposal_version']), ([JASON, CARMEN], 2))
+
+
+ # ----- upgrade: rows stored before the approver rule -------------------------------------------------------
+ def plant_legacy_decision(self, approvers, status='Proposed', **extra):
+ """A decision whose stored list predates the rule, as the base code could write it. Created valid, then rewritten
+ in the test database only, with the digest recomputed so the stored version and digest stay consistent."""
+ from setspark_api.digest import decision_digest
+ dec = self.decision(**extra)
+ row = self.sql('UPDATE decisions SET required_approvers = %s::jsonb WHERE id = %s RETURNING *', json.dumps(approvers), dec['id'])[0]
+ self.sql('UPDATE decisions SET proposal_digest = %s, status = %s WHERE id = %s', decision_digest(row), status, dec['id'])
+ return self.svc.execute(self.coord, 'get', {'id': dec['id']})[1]
+
+ def plant_legacy_request(self, dec):
+ """An open, bound approval request copied from a legacy decision, as the base open_approval_request wrote it."""
+ row = self.sql('''INSERT INTO approval_requests (decision_id, proposal_version, proposal_digest, required_approvers, message_id, channel_id)
+ VALUES (%s, %s, %s, %s::jsonb, %s, %s) RETURNING *''', dec['id'], dec['proposal_version'], dec['proposal_digest'],
+ json.dumps(dec['required_approvers']), uuid.uuid4().hex, '200000000000000000')[0]
+ return {'request_id': row['id'], 'message_id': row['message_id']}
+
+ def test_upgrade_legacy_decision_cannot_open_a_request(self):
+ for label, approvers in (('17 ids', [_fake_approver(i) for i in range(17)]), ('duplicate ids', [JASON, JASON]),
+ ('bare names', ['fixture-name-a', 'fixture-name-b']), ('pending marker', [JASON, 'pending:Fixture approver B'])):
+ with self.subTest(case=label):
+ dec = self.plant_legacy_decision(approvers)
+ with self.assertRaises(ApiError) as cm:
+ self.svc.execute(self.sage, 'open_approval_request', {'decision_id': dec['id'], 'proposal_version': dec['proposal_version'],
+ 'proposal_digest': dec['proposal_digest']}, self.key())
+ self.assert_invalid_without_echo(cm, approvers, code='approvers_not_ready')
+ self.assertEqual(self.sql('SELECT count(*) AS n FROM approval_requests WHERE decision_id = %s', dec['id'])[0]['n'], 0)
+
+ def test_upgrade_legacy_request_cannot_collect_approvals_or_seal(self):
+ for label, approvers in (('17 ids', [_fake_approver(i) for i in range(17)]), ('mixed pending and id', [JASON, 'pending:Fixture approver B'])):
+ with self.subTest(case=label):
+ dec = self.plant_legacy_decision(approvers)
+ req = self.plant_legacy_request(dec)
+ with self.assertRaises(ApiError) as cm:
+ self.approve(req, approvers[0])
+ self.assert_invalid_without_echo(cm, approvers, code='approvers_not_ready')
+ self.assertEqual(self.sql('SELECT count(*) AS n FROM approvals WHERE decision_id = %s', dec['id'])[0]['n'], 0)
+ self.assertEqual(self.sql('SELECT state FROM approval_requests WHERE id = %s', req['request_id'])[0]['state'], 'open')
+ self.assertEqual(self.svc.execute(self.coord, 'get', {'id': dec['id']})[1]['status'], 'Proposed')
+ # the way out: correcting the list bumps the version, so the old request goes stale and closes
+ _, fixed = self.svc.execute(self.coord, 'update', {'id': dec['id'], 'revision': dec['revision'], 'fields': {'required_approvers': [JASON, CARMEN]}}, self.key())
+ self.assertEqual(fixed['proposal_version'], dec['proposal_version'] + 1)
+ with self.assertRaises(ApiError) as cm:
+ self.approve(req, JASON)
+ self.assertEqual(cm.exception.code, 'request_stale')
+ self.assertEqual(self.accept(dec['id'])['status'], 'Accepted')
+
+ def test_upgrade_legacy_accepted_decision_cannot_be_superseded(self):
+ legacy = [_fake_approver(i) for i in range(17)]
+ old = self.plant_legacy_decision(legacy, status='Accepted')
+ succ = self.decision(supersedes=old['id'])
+ req = self.open_request(succ['id'])
+ self.approve(req, JASON)
+ with self.assertRaises(ApiError) as cm: # the completing approval would seal succ and supersede old
+ self.approve(req, CARMEN)
+ self.assert_invalid_without_echo(cm, legacy)
+ self.assertEqual(self.svc.execute(self.coord, 'get', {'id': succ['id']})[1]['status'], 'Proposed')
+ self.assertEqual(self.sql('SELECT count(*) AS n FROM approvals WHERE decision_id = %s', succ['id'])[0]['n'], 1)
+ # the supersede verb, with a successor that is already Accepted (planted, as a pre-rule acceptance could leave it)
+ self.sql("UPDATE decisions SET status = 'Accepted' WHERE id = %s", succ['id'])
+ with self.assertRaises(ApiError) as cm:
+ self.svc.execute(self.coord, 'supersede', {'decision_id': old['id'], 'successor_id': succ['id']}, self.key())
+ self.assert_invalid_without_echo(cm, legacy)
+ now = self.svc.execute(self.coord, 'get', {'id': old['id']})[1]
+ self.assertEqual((now['status'], now.get('superseded_by'), now['required_approvers']), ('Accepted', None, legacy))
+
+
+def _discord(digits):
+ """discord:<digits ASCII digits>, obviously fake: 1, zeros, 1."""
+ return 'discord:1' + '0' * (digits - 2) + '1'
+
+
+def _fake_approver(i):
+ return 'discord:%d' % (100000000000000001 + i)
+
+
+BAD_APPROVERS = (
+ ('bare name', ['fixture-name-a']),
+ ('bare id', ['100000000000000001']),
+ ('16 digits', [_discord(16)]),
+ ('23 digits', [_discord(23)]),
+ ('arabic-indic digits', ['discord:' + '\u0661' * 18]),
+ ('fullwidth digits', ['discord:' + '\uff11' * 18]),
+ ('trailing newline', [JASON + '\n']),
+ ('prefix case', ['Discord:100000000000000001']),
+ ('empty pending text', ['pending:']),
+ ('blank pending text', ['pending: ']),
+ ('duplicate', [JASON, JASON]),
+ ('zero entries', []),
+ ('17 entries', [_fake_approver(i) for i in range(17)]),
+)
+
class HttpTests(unittest.TestCase):
@classmethod