Packet, Rocko's R1 and R2 reviews, DEFERRED outcome and SESSIONS. Co-Authored-By: Claude Opus 5.5 <[email protected]>
21 KiB
SetSpark required_approvers fix: review notes (revision 2)
Repository: /mnt/storage/src/shared-signals (jetrich/shared-signals), branch main. Base commit: 492548af3b48d039023b5ece2e7943eb6d15556f. The tree was clean before the work. Nothing was committed, pushed, branched, stashed or reset. Sage commits after review.
This revision answers Rocko's review of the first packet (fix.patch e4dfc1e5..., report /mnt/storage/src/mosaic-stack/agents/rocko/work/setspark-approvers-review-2026-09-26.md, sha256 706e9ac1...). The next section lists what changed since then; the rest describes the patch as a whole.
Packet files:
- fix.patch: output of
git -C /mnt/storage/src/shared-signals diffagainst the base (416 lines). It replaces the e4dfc1e5 patch; it is not an increment on top of it. - manifest.sha256: sha256 of each changed file after the patch, paths relative to the
repository root. The patch was applied to a fresh export of the base commit and
sha256sum -c manifest.sha256passed for all three files.
Changes since e4dfc1e5
Rocko's blocking finding was reproduced before the fix. Rows written by the base code (a 17-approver Proposed decision with an open request, and a 17-approver decision sealed as Accepted) let e4dfc1e5 do three things:
- (a) open a new request with 17 approvers;
- (b) collect 17 approvals through the old request and seal the decision as Accepted;
- (c) supersede the sealed legacy decision when a valid successor was accepted.
Changes in service.py:
- New
approvers_ready(approvers). It returns True only when the list passescheck_required_approversin its Accepted form: 1 to 16 distinctdiscord:<id>entries and no pending markers. It calls the same helper, so there is still one rule and no second copy of it. _open_approval_requestnow requiresapprovers_readyon the stored decision list. e4dfc1e5 checked only that every entry was a Discord id, which let 17 entries and duplicates through. Error: 422approvers_not_ready, fixed message._add_approvalnow requiresapprovers_readyon both the request's stored copy and the decision's current list. The check runs after the existingnot_open,request_staleandimport_pendingchecks and beforenot_approverand any write, so a refusal writes no approval, no status change and no request state change. Error: 422approvers_not_ready. Seal to Accepted happens only inside_add_approval, after this check, so this one guard is also the seal guard. I did not add a second check at the seal write: it would be the same predicate on the same row in the same transaction, so it could never fire, and no test could prove it._link_supersessionnow refuses when the old decision's stored list is not ready (the rule for Superseded). Both supersession routes go through it: thesupersedeverb, and_add_approvalwhen the accepted decision carriessupersedes. Error: 422invalid_record, because the write is to a decision, not a request. The message names the old decision's id and states the rule; it does not include approver values. When the route is_add_approval, the refusal rolls back the completing approval and the successor's seal together.
Other changes:
- The approvers_not_ready message now states the full readiness rule.
approvers_not_readyis the code for open_approval_request and add_approval, because both are about collecting approvals.invalid_recordis the code for supersession, because the refused write is the decision's status.- README: the open_approval_request, add_approval and supersede rows describe the new checks. A new paragraph explains why stored rows are rechecked. The approvers_not_ready error row covers add_approval.
- README: the second SQL query now uses the full readiness rule (length 1 to 16, distinct, Discord ids only), not just "not a Discord id".
- README: a paragraph now says that an Accepted row with a broken list cannot be corrected through the API, because the immutability trigger freezes it, and so cannot be superseded. See "Open question for the owner" below.
- Tests: three upgrade regressions and two helpers that plant pre-rule rows (described below).
- Tests:
assert_invalid_without_echonow also checksjson.dumps(body()),repr(exc)andstr(exc), not only the message. - NOTES: resolve_links was wrongly described as revalidating through
_validate. It callsMODELS[rtype].model_validate, which type-checks the record and does not call the approver helper. It never writes approvers, and finalize_import later runs_validateon the final record.
What changed and why (whole patch)
The defect: setspark-api accepted any distinct nonempty strings as a decision's
required_approvers. A decision such as DEC-009, stored with bare names, could never be
approved, because add_approval compares the Discord author id against that list.
stack/api/setspark_api/service.py:
-
check_required_approvers(approvers, status)applies the rule:- the list has 1 to 16 entries;
- each entry is
discord:<id>matched with the existingDISCORD_ID_RE(fullmatch), orpending:<text>whose text is nonempty afterstrip(); - entries are distinct;
- pending markers are refused when status is Accepted or Superseded.
Every failure is 422
invalid_recordwith a fixed message that states the rule and never includes the submitted value. -
DISCORD_ID_REwas alreadydiscord:[0-9]{17,22}, with ASCII[0-9]rather than\d. It needed no tightening and was not changed. It is the same patternvalidate_vault.pyuses at line 167, so the API and the validator share one digit rule. -
_validatefor decisions calls the helper in place of the old check (evidenceplus distinct)._validatecovers create, import, update (the merged record) and finalize_import. -
approvers_readyguards open_approval_request, add_approval (and so the seal) and supersession, as described above. -
The older check in
_import_approvals(Discord ids required for an Accepted or Superseded import) was left in place. It is now redundant with the helper but harmless, and removing it was out of scope.
tools/tests/test_setspark_api.py: eleven new ServiceTests methods, a BAD_APPROVERS table,
two id helpers and two legacy-row helpers (listed below).
stack/api/README.md:
- A "Required approvers" section states the rule and where each part is enforced.
- Two read-only SQL queries: decisions whose stored list breaks the rule, and open approval requests whose list is not ready. DEC-009 is named as the known case.
approvers_not_readyis added to the error table.- The affected verb rows are updated.
No data migration was written. No database was touched except throwaway test databases.
Pending markers follow validate_vault.py lines 160-169 exactly, as the coordinator ruled.
The validator requires Discord ids only when status is Accepted or Superseded. It does not
restrict Proposed or Rejected, so the API allows pending markers on Proposed and Rejected
and refuses them on Accepted and Superseded.
docs/RECORDS.md line 55 says "While Proposed, required_approvers may contain explicit pending markers". A Rejected decision can only be reached from Proposed and keeps its list, so allowing pending markers on Rejected matches the validator without contradicting the convention. validate_vault.py, RECORDS.md and the decision template are unchanged.
stack/api/openapi.json is unchanged. It is generated from routes, and no route, request
model or response model changed. python -m setspark_api.app --openapi output was compared
byte for byte with the committed file and is identical.
Write paths and coverage
Every code path that writes a decision's approvers or status, or an approval request:
| Path | Writes | Guarded by | Test |
|---|---|---|---|
create (_create, _insert) |
decisions | _validate then helper |
create refuses 13 bad forms; create accepts ids, 17 and 22 digits, a pending marker, 16 entries |
update (_update, _write) |
decisions | _validate on the merged record |
update refuses 13 bad forms, revision and list unchanged; valid pending update works; title-only update of a legacy row refused; update that fixes the list works; a Rejected legacy row can be fixed (checked in the SQL script) |
import (_import, _insert, sealed status UPDATE) |
decisions | _validate, and _import_approvals before the sealed status write |
import refuses 13 bad forms and stores nothing; Accepted import with a pending marker refused |
| finalize_import | import_pending flag | _validate on the stored row |
stored legacy row refused at finalize |
| open_approval_request | approval_requests (copies the list) | approvers_ready on the decision |
pending marker refused, no row written, opens after the fix; upgrade test (a): 17 ids, duplicate ids, bare names, pending marker |
| add_approval, including the seal to Accepted | approvals, decisions, approval_requests | approvers_ready on request copy and decision, before any write |
upgrade test (b): 17-id legacy request and mixed pending/id legacy request refused, nothing written; after the list is fixed the old request goes stale and a new one accepts |
supersession (supersede verb, successor acceptance) |
decisions | approvers_ready on the old decision in _link_supersession |
upgrade test (c): both routes refused, successor stays Proposed, legacy row unchanged |
Paths confirmed not to write approvers or status:
- resolve_links writes link columns only. It type-checks with
model_validate, not_validate, and finalize_import runs_validateafterwards. - bind_approval_message writes message and channel ids only.
stack/mirrorreads the DB and runs validate_vault.py; it never writes the DB.stack/migrate(ApiSink) writes only through/v1/importand/v1/import/finalize.stack/api/setspark_api/migrate.pyhandles schema and counters only.- REST and MCP both enter through
Service.execute.
The only three writes that set Accepted or Superseded are:
- the import seal (guarded by
_validateand_import_approvals); - the add_approval seal;
_link_supersession.
Compatibility
- Vault: DEC-001 to DEC-008 are all Proposed, each with two
pending:markers. All pass the helper (asserted in the migration-shaped test). - The existing digest-equality test imports all eight real DEC files through the API and still passes.
python3 tools/validate_vault.py: PASS on 45 structured records.- The API rule is a strict subset of the validator's rule, so the mirror cannot publish a record the validator refuses. The validator accepts bare names on a Proposed decision and the API does not. No current vault record has that shape.
- Migration: an import in the vault shape that ApiSink sends (Proposed, pending markers,
approvals: []) still imports and finalizes. - Stored legacy Proposed or Rejected rows: any update is refused until the same update
corrects
required_approvers. Opening a request or approving is refused until then. After the correction, an open request from before it goes stale on the next add_approval, because the list is digest-covered and the version bumps. - The ordinary valid approval path is unchanged. All existing approval, acceptance and supersession tests pass.
- Error message change: the old invalid_record text "Decision requires distinct, explicit
required_approvers" is replaced.
git grepin shared-signals and in mosaic-stack (which holds the Discord connector) found no caller that matches on the old text or on approvers_not_ready.
Open question for the owner
An Accepted decision sealed before the rule with a broken list cannot be corrected: the
immutability trigger freezes required_approvers on Accepted rows. Under this revision it
also cannot be superseded.
The only such shape the base code could produce is more than 16 distinct Discord ids. The old code needed every approver to approve, so bare names or pending markers could never seal. An import also required Discord ids.
The README's first query lists any such row. Whether one exists in production is unknown. If one does, unblocking it needs an owner decision, for example a one-off reviewed data correction, which this change deliberately does not include.
Tests and fail-without-fix evidence
New tests in tools/tests/test_setspark_api.py (ServiceTests). Every refusal is checked
with assert_invalid_without_echo: status and code, and no submitted value in the
message, body JSON, repr or str.
test_approvers_create_refuses_every_malformed_form_without_echo: 13 subTests. The cases are bare name, bare id, 16 digits, 23 digits, Arabic-Indic digits, fullwidth digits, trailing newline,Discord:prefix case, empty pending text, blank pending text, duplicate, 0 entries and 17 entries.test_approvers_create_accepts_ids_pending_markers_and_bounds: one id, 17 and 22 digit ids, a pending marker on a Proposed create, and 16 entries.test_approvers_update_refused_the_same_way: the 13 cases, each on a fresh decision, with revision and list unchanged. Then a valid pending update bumps proposal_version.test_approvers_import_refused_the_same_way: the 13 cases, each on an unused id, with nothing stored. Then an Accepted import with a pending marker is refused.test_approvers_pending_markers_follow_the_validator_status_rule: calls the helper directly for all four statuses.test_approvers_open_request_refused_until_every_approver_is_a_discord_id: a pending marker gets approvers_not_ready with no row written; after an update to two ids, the request opens.test_approvers_migration_shaped_import_with_pending_markers_still_finalizes: an import in the ApiSink shape imports and finalizes, and then open is refused. Every real vault DEC file passes the helper.test_approvers_stored_legacy_row_is_refused_at_finalize_update_and_open: a DEC-009-shaped row is refused at finalize, at open and on a title-only update. A correcting update succeeds.test_upgrade_legacy_decision_cannot_open_a_request, upgrade part (a): 4 subTests (17 ids, duplicate ids, bare names, pending marker). Each gets approvers_not_ready and no request row is written.test_upgrade_legacy_request_cannot_collect_approvals_or_seal, upgrade part (b): 2 subTests (17 ids, mixed pending and id).- A legacy decision with a legacy open, bound request.
- The first approval is refused with approvers_not_ready. No approval row is written, the request stays open and the decision stays Proposed.
- A correcting update then makes the old request return request_stale, and a new request accepts normally.
test_upgrade_legacy_accepted_decision_cannot_be_superseded, upgrade part (c).- A legacy Accepted decision with 17 ids.
- A valid successor's completing approval is refused with invalid_record. The successor stays Proposed with only its first approval.
- With the successor planted as Accepted, the
supersedeverb is also refused. - The legacy row stays Accepted, with no superseded_by and its list unchanged.
How the upgrade tests build legacy state: two helpers write it in the test database only. They do not load the base code.
plant_legacy_decisioncreates a valid decision, rewrites its list by SQL and recomputesproposal_digestwithdecision_digest, so version and digest stay consistent.plant_legacy_requestinserts the open, bound request row the base open_approval_request would have written.
To confirm that the planted state matches real pre-fix behaviour, I also ran Rocko's scenario with the real base code, out of tree, in /tmp/ssapi-upgrade-repro.py:
- It loads service.py from commit 492548af with
git show. - On a fresh database it uses the base code to create a 17-approver decision with an open request, a 17-approver decision sealed Accepted through 17 approvals, and a mixed pending/id decision with an open request.
- It then drives the working-tree service on the same database.
| Scenario step | with e4dfc1e5 | with this revision |
|---|---|---|
| (a) open a new request on the 17-approver decision | allowed, 17 approvers | 422 approvers_not_ready |
| (b) approvals through the old 17-approver request | allowed; after 17, accepted, status Accepted | 422 approvers_not_ready on the first |
| (b) approval through the old mixed request | allowed | 422 approvers_not_ready |
| (c) successor acceptance superseding the sealed legacy decision | allowed; legacy became Superseded | 422 invalid_record; legacy stays Accepted, superseded_by none |
Proofs by guard: I ran the API module (57 tests) on a fresh database once per service.py
variant, and restored the fixed file afterwards. cmp against a saved copy confirmed the
restore.
| service.py variant | Result | Failing new checks |
|---|---|---|
| base (whole fix reverted) | failures=43, errors=1 | create/update/import 11 of 13 cases each; tests 6, 7, 8, 11; all 4 cases of test 9; both cases of test 10; test 5 errors (no helper) |
| e4dfc1e5 (the reviewed packet) | failures=5 | test 9: 17 ids and duplicate ids; test 10: both cases; test 11 |
this revision without the _validate change |
failures=34 | create/update/import 11 cases each; test 8 |
| this revision without any open_approval_request check | failures=7 | tests 6, 7, 8; all 4 cases of test 9 |
| this revision with open reverted to the e4dfc1e5 format-only check | failures=2 | test 9: 17 ids and duplicate ids |
| this revision without the add_approval guard | failures=2 | test 10: both cases |
| this revision without the supersession guard | failures=1 | test 11 |
| this revision | 57 run, OK | none |
Each new guard has a test that fails when only that guard is removed.
Some checks pass without the fix. They are not proofs:
- The duplicate and 0-entry cases in tests 1, 3 and 4. The old check already refused them.
- Test 2 and the final valid update in test 3, which are positive guards.
- The Accepted-with-pending import in test 4. The old
_import_approvalsalready refused it. - The import and finalize half of test 7.
- The bare-names and pending cases of test 9 under e4dfc1e5, which already refused them.
README SQL verification
I extracted both queries from the README and ran them on a throwaway pgserver database (PostgreSQL 16.2, datctype en_US.UTF-8). The seeded rows:
- decisions: 59 approver lists under each of 4 statuses, 236 rows. The lists include every Python whitespace character alone as pending text, non-arrays, non-string elements, duplicates, and 0, 16 and 17 entries.
- approval_requests: the same 59 lists in each of the open, closed and approved states, 177 rows.
Rows were seeded with session_replication_role = replica. Each row's SQL verdict was
compared with check_required_approvers for decisions and with approvers_ready for open
requests.
| Table | Flagged by Python | Flagged by SQL | Mismatches |
|---|---|---|---|
| decisions | 208 | 208 | 0 |
| requests | 54 | 54 | 0 |
The request query flags 54 now, against 52 under e4dfc1e5's narrower Discord-only query. The two extra rows are the 17-entry and duplicate lists.
The pending-text test spells out the exact set of characters that str.isspace()
accepts, not [:space:], so its result does not depend on the database locale.
The same script confirms that a Rejected legacy row can be corrected through update.
It is /tmp/ssapi-sqlcheck.py, outside both repositories.
Commands and counts
python3 -m unittest discover -s tools/tests -v
system Python 3.12.8, no API deps: Ran 131, OK (skipped=1; the API module skips itself)
SETSPARK_TEST_DATABASE_URL=<fresh pgserver db> /tmp/ssapi-venv/bin/python -m unittest discover -s tools/tests -v
Ran 188, OK, three consecutive runs on three fresh databases
(API module alone: 57 tests; base had 46, so 11 are new; e4dfc1e5 had 54)
python3 tools/validate_vault.py
PASS: 45 structured records, exit 0
(cd stack/api && python -m setspark_api.app --openapi | cmp - openapi.json)
identical
git diff --check
clean
Environment: the venv /tmp/ssapi-venv has psycopg 3.3.6, fastapi 0.141.1 and pgserver.
/tmp/ssapi-fresh-db.py creates an empty database on a local embedded server and prints its
URL. All added lines in the diff are ASCII; the non-ASCII test inputs are written as
\u escapes.
What could not be verified
- The suite normally runs against a
postgres:17container. Docker was off limits, so every database run used pgserver's embedded PostgreSQL 16.2 throughSETSPARK_TEST_DATABASE_URL. Nothing in the change depends on a 17-only feature, but it has not been run on 17. - The live SetSpark database was not touched, and the README queries were not run on
production. Still unknown:
- whether DEC-009 is the only affected decision;
- whether any Accepted row holds more than 16 approvers (see the open question);
- whether any open request holds a list that is not ready.
- The deployed service was not exercised. The change takes effect when Sage's reviewed commit is deployed.
- An existing test,
test_supersede_reciprocity_and_acyclicity, failed once in an early run of the first packet with duplicate_evidence or already_approved. The helperapprove()draws a random source URL index in 0 to 89999, so two draws can collide. It did not recur in any later run, including every run for this revision. It is an existing flake and was left alone.