Files
stack/agents/sage/work/setspark-approvers/NOTES.md
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

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 diff against 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.sha256 passed 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:

  1. New approvers_ready(approvers). It returns True only when the list passes check_required_approvers in its Accepted form: 1 to 16 distinct discord:<id> entries and no pending markers. It calls the same helper, so there is still one rule and no second copy of it.
  2. _open_approval_request now requires approvers_ready on the stored decision list. e4dfc1e5 checked only that every entry was a Discord id, which let 17 entries and duplicates through. Error: 422 approvers_not_ready, fixed message.
  3. _add_approval now requires approvers_ready on both the request's stored copy and the decision's current list. The check runs after the existing not_open, request_stale and import_pending checks and before not_approver and any write, so a refusal writes no approval, no status change and no request state change. Error: 422 approvers_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.
  4. _link_supersession now refuses when the old decision's stored list is not ready (the rule for Superseded). Both supersession routes go through it: the supersede verb, and _add_approval when the accepted decision carries supersedes. Error: 422 invalid_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_ready is the code for open_approval_request and add_approval, because both are about collecting approvals. invalid_record is 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_echo now also checks json.dumps(body()), repr(exc) and str(exc), not only the message.
  • NOTES: resolve_links was wrongly described as revalidating through _validate. It calls MODELS[rtype].model_validate, which type-checks the record and does not call the approver helper. It never writes approvers, and finalize_import later runs _validate on 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 existing DISCORD_ID_RE (fullmatch), or pending:<text> whose text is nonempty after strip();
    • entries are distinct;
    • pending markers are refused when status is Accepted or Superseded.

    Every failure is 422 invalid_record with a fixed message that states the rule and never includes the submitted value.

  • DISCORD_ID_RE was already discord:[0-9]{17,22}, with ASCII [0-9] rather than \d. It needed no tightening and was not changed. It is the same pattern validate_vault.py uses at line 167, so the API and the validator share one digit rule.

  • _validate for decisions calls the helper in place of the old check (evidence plus distinct). _validate covers create, import, update (the merged record) and finalize_import.

  • approvers_ready guards 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_ready is 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 _validate afterwards.
  • bind_approval_message writes message and channel ids only.
  • stack/mirror reads the DB and runs validate_vault.py; it never writes the DB.
  • stack/migrate (ApiSink) writes only through /v1/import and /v1/import/finalize.
  • stack/api/setspark_api/migrate.py handles 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 _validate and _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 grep in 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. test_approvers_pending_markers_follow_the_validator_status_rule: calls the helper directly for all four statuses.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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.
  11. 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 supersede verb 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_decision creates a valid decision, rewrites its list by SQL and recomputes proposal_digest with decision_digest, so version and digest stay consistent.
  • plant_legacy_request inserts 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_approvals already 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:17 container. Docker was off limits, so every database run used pgserver's embedded PostgreSQL 16.2 through SETSPARK_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 helper approve() 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.