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]>
This commit is contained in:
2026-09-26 18:52:04 -05:00
co-authored by Claude Opus 5.5
parent 40a02d2bcb
commit f87cd6e201
8 changed files with 1063 additions and 0 deletions
@@ -0,0 +1,139 @@
# Shared Signals required_approvers — independent review
Verdict: **revise**. Rocko for Sage, 2026-09-26.
Pins verified:
- Base: `492548af3b48d039023b5ece2e7943eb6d15556f`
- fix.patch: `e4dfc1e56ad7a667e4af94920bb864197c128875dc3a5420b2af5ce65459ab0a`
- manifest.sha256: `227e87a1d345f6b96beca863ed542265d9b653663221329a330a6544a0627372`
- NOTES.md: `d2e60e47546b11c0e6ba59608ea3bf610eb9dc15e972785e088eccd7c5f00513`
Reviewed in `/tmp/rocko-setspark-review-BromBE`, a shared clone detached at
the specified base with the packet applied. All three manifest entries
matched before testing and afterward. The source checkout was not edited.
Lead decision 21 supplies the approver ruling; current item 23 is about
queue A1, not an additional approver rule. That reference mismatch does not
prevent reviewing the explicit assignment.
## 1. Blocking, medium — legacy requests and direct status writes bypass the new invariant
The helper enforces 1..16 entries, but `_open_approval_request` checks only
nonempty list and Discord-id format. It does not enforce the upper bound
or distinctness on stored legacy rows. More importantly, `_add_approval`
can change a decision to Accepted through `_write` without calling the
helper. `_link_supersession` similarly changes it to Superseded without
checking its approvers.
**Executed, using authentic pre-fix writes rather than corrupting SQL:**
1. Load base service.py from commit 492548af as a separate Python module
against a fresh embedded Postgres. Create a Proposed decision with 17
distinct valid Discord approvers and open/bind its request. The old
validator accepts this shape and computes the real proposal digest.
2. Switch to the patched Service instance, using the same database.
3. Open a second request for that same decision. It succeeds with 17
approvers: `NEW_OPEN_LEGACY17 17`.
4. Submit all 17 approvals through the **old** request using the patched
service. The last response reports `accepted: true, status: Accepted`.
5. Create and approve a valid successor. The old decision becomes
`Superseded`, still with 17 required approvers.
Thus existing records do not merely remain untouched until remediation:
new approval-request and sealing writes carry forward a list the new
helper rejects. This is not a claim that those approvals were forged;
it is a demonstrated bypass of the newly promised validation invariant.
The 185-test suite does not cover this upgrade scenario.
**Fix:** validate the full stored list before opening a request. Revalidate
both request and current decision readiness at the approval mutation
boundary, including requests created before deployment, and enforce the
sealed-status helper rule before Accepted/Superseded writes. Keep checks
inside the existing transaction so refusal leaves no new approval, status
change or partial supersession. Preserve existing evidence and idempotent
historical receipts; do not introduce an automatic data migration.
**Acceptance:** construct a legacy 17-approver decision/request through
base behavior, then upgrade in place. New open, add/seal and supersession
must refuse under a documented fixed error, without values or partial
writes. Also cover an old mixed pending/id request: readiness cannot be
assumed just because a request row exists. Keep the ordinary current-valid
approval path green. Align the open-request diagnostic SQL with the full
readiness rule if that rule is broadened to include these bounds.
## 2. Not blocking — NOTES overstates resolve_links validation
The packet says resolve_links revalidates through `_validate`. It actually
calls `MODELS[rtype].model_validate`, which checks structural types and does
not invoke check_required_approvers. It does not write approvers, and later
finalize_import does use `_validate`, so this is not another way to import
an invalid final record through the reviewed path. Correct the claim so the
path audit does not imply broader enforcement than exists.
## Other requested checks
**Create/update/import/finalize:** their `_validate` calls reach the helper.
Update validates the merged record and direct update cannot newly set
Accepted/Superseded. Import validates before its special sealed-status and
approval-evidence writes. `_import_approvals` already requires stable ids;
its older pending rejection is a legitimate redundant guard. Current
migration imports and finalization remain compatible.
**Error-value exposure:** the new helper and readiness errors carry fixed
messages, no submitted values or extra detail. ApiError initializes its
Exception args with that safe message; its ordinary repr is safe. REST
returns body(), and MCP wraps that same body. Pydantic validation is
converted to field locations/error kinds, not raw input, and database error
logging here uses exception class names. I found no new approver-value
leak in these refusal paths. This is not a claim that successful record
reads/audit snapshots hide stored approvers: those deliberately contain
record data. The added tests check messages; body/extra/repr and log capture
would strengthen the explicit no-echo boundary without replacing the
source inspection.
**README SQL:** independently extracted and executed both queries against
a separate disposable PostgreSQL 16.2 instance. Tested 296 decision cases
(74 lists under four statuses) and 74 open-request cases, including all 29
Python isspace characters, whitespace around nonempty pending text,
zero-width space, Arabic-Indic/fullwidth digits, trailing newline/space,
nonarrays, nonstrings, duplicates and 17 entries. No mismatch with the
Python helper for decisions or the current Discord-only readiness predicate
for requests. The explicit whitespace class matches strip for these cases.
The second query is narrower than full list validity, as finding 1 notes.
No query was run on production.
**Tests that pass without the fix:** NOTES distinguishes duplicate/empty
list checks, positive compatibility cases and the old sealed-import guard
from new regression evidence. That is honest. A missing-helper error in
test 5 proves test sensitivity to removal, not by itself every status
branch; its direct positive/refusal assertions still exercise those branches.
The absent upgrade/old-request test is the material gap, not the inclusion
of existing guards.
**Vault and mirror:** the API is deliberately stricter than the vault
validator on Proposed/Rejected entries and on cardinality. Current pending
markers are retained, and the current vault passes. The successful full
suite includes migration and mirror tests. Existing arbitrary Proposed
names elsewhere would now require explicit correction before import; no
such current-vault regression was observed. No data migration is included.
## Independent verification
- System `python3 -m unittest discover -s tools/tests -v`: 131 tests,
OK, one API-module skip for missing dependencies.
- `python3 tools/validate_vault.py`: PASS, 45 structured records.
- With `/tmp/ssapi-venv/bin/python` and an explicitly supplied fresh local
pgserver database: full discovery, 185 tests, OK, exit 0 (39.624 seconds).
- Separate fresh database: upgrade reproduction above, all three observed
bypasses confirmed.
- Separate fresh database: SQL/Python comparison, zero mismatches.
- Manifest and `git diff --check` passed in the review copy.
The first attempt to run the extra upgrade fixture reused the suite's DB
and stopped at duplicate synthetic test-key setup. It made no upgrade
finding; the successful reproduction used another fresh DB and the base
service to create the legacy state. Test servers were stopped by pgserver
cleanup. No Docker, VM 1022, live service, or ~/.config/setspark key file
was used. PostgreSQL 17 was not exercised.
Only this report was written in the Mosaic checkout. No commit, push,
deployment or edit to the shared-signals source checkout.
@@ -0,0 +1,123 @@
# Shared Signals approvers R2 — independent review
Verdict: **approve**. Rocko for Sage, 2026-09-26.
Verified pins:
- Base: `492548af3b48d039023b5ece2e7943eb6d15556f`
- fix.patch: `41e735f4e6a31e6f7011054dcfc819cd89c9b1b6ce8efc8f22742146984c0cec`
- manifest.sha256: `23c0c52cfb40566ad4d62cc9543390ad919331881036a8d183d673e5d5066788`
- NOTES.md: `804256cf7569c447eb11d42d2045725faf77e850b9db21ed69a702be7be63c8b`
Reviewed in a fresh shared clone, `/tmp/rocko-setspark-r2-dd5SY9`, detached
at the base with the full patch applied. All three manifest entries matched
before and after verification. No blocking finding remains from report
706e9ac1.
## 1. Approval and sealing coverage is correct
Open uses the full Accepted-form readiness predicate. add_approval checks
both the stored request copy and current decision list before inserting
approval evidence. Both records are locked in the transaction; no later
step changes either list before the status write. This covers its seal.
The other path to Accepted is import. It calls _validate with the final
requested status before temporarily inserting Proposed, then verifies
imported approval evidence before writing the final status. Thus its
Accepted/Superseded import path already reaches the strict helper. Ordinary
update cannot newly set those sealed statuses and validates the merged
record. Finalization validates unfinished imports; resolve_links only
changes links and type-checks, as the corrected NOTES now says.
Both supersession routes call _link_supersession, which refuses a bad
predecessor before changing it. The already-Superseded/same-successor early
return performs no new status write; returning existing history is not a
new invalid seal. Existing idempotent success receipts likewise remain
historical responses, not new writes.
## 2. Rollback verified with real base-created states
In another fresh embedded database I loaded service.py from commit
492548af as a separate module. Through that base service, without planting
invalid decision rows, I created:
- a 17-id Proposed decision with a bound open request;
- a mixed pending/id Proposed decision with a bound open request;
- a 17-id Accepted predecessor, completed through all 17 approval calls;
- an Accepted imported successor pointing to that predecessor, still
import_pending (a legitimate intermediate migration state).
After switching to R2 on the same database:
- opening another request on the 17-id decision refused approvers_not_ready;
- the first approval on each old 17-id/mixed request refused approvers_not_ready;
- a normal valid successor's completing approval refused invalid_record;
- the explicit supersede verb using the genuinely imported Accepted
successor also refused invalid_record.
For the completing-approval refusal I compared complete rows before/after
for both decisions, successor approvals, the request and affected audit
entries. All were identical. The existing first approval remained; the
second approval, successor seal/snapshot and predecessor update did not
remain. A failed-command receipt is intentionally committed outside the
handler savepoint; “nothing half-written” does not mean no failure receipt.
## 3. Nonblocking fixture qualifications
Not every planted row is literally a base-produced state:
- the base validator already rejected duplicate required approvers, so the
duplicate case is defensive corrupt-state coverage, not upgrade evidence;
- plant_legacy_decision(status=Accepted) does not create the approval rows
and accepted snapshot that a real base seal produces;
- the direct-supersede test manually marks a successor Accepted while its
predecessor remains Accepted. Normal completing approval would update
both atomically, so that exact planted ordinary-approval history is not
achievable. A still-pending Accepted import supplies a real reachable
state for testing the explicit supersede route instead.
These limitations should be labelled in the test descriptions/NOTES.
They do not block this candidate: the independent base-code reproduction
above covers the meaningful upgrade and rollback properties with genuine
states. The author also reports a separate base-code reproduction.
## 4. Accepted operational consequence, with one correction
The sealed >16-id predecessor is intentionally unrecoverable through normal
update and cannot be superseded after this change. That is Sage's explicit
fail-closed ruling; no new correction mechanism is requested here. A valid
successor can collect partial approvals but cannot complete while linked to
that predecessor. The transaction keeps its attempted completion atomic.
Do not justify rarity solely by “more than 16 real approvals.” The base
coordinator import path could seal from supplied, structurally checked
approval evidence; it does not require 17 live connector interactions or
independently authenticate the cited Discord messages. Both ordinary seals
and historical imports therefore belong in the README query's operational
survey. This does not widen the accepted failure mode, but it weakens the
proposed reason to assume no affected record exists. A discovered sealed
case still goes to Jason for the separately reviewed correction, as ruled.
## Verification
- System `python3 -m unittest discover -s tools/tests -v`: 131 tests, OK,
one API-module dependency skip.
- `python3 tools/validate_vault.py`: PASS, 45 structured records.
- Full discovery with the existing test venv and a fresh explicitly
configured embedded Postgres: 188 tests, OK, exit 0 (39.711 seconds).
- Independent base-code upgrade/rollback experiment: all refusals and
unchanged-state assertions passed.
- Updated README queries independently compared with Python on 296
decision cases and 74 open-request cases, including all 29 Python
whitespace characters and Unicode digits: zero mismatches. Request SQL
now covers the full readiness rule, including bounds and duplicates.
- Manifest checks and git diff --check passed in the copy.
No new value-echo path was introduced by the guards: errors remain fixed
text, with a record id in the supersession diagnostic rather than submitted
approver values. Current vault migration/mirror compatibility remains green.
PostgreSQL 16.2 was exercised; PostgreSQL 17 and production were not.
Test servers were stopped through pgserver cleanup. No Docker, VM 1022,
~/.config/setspark key file, live service, or source-checkout edit was used.
Only this report was written in the Mosaic checkout. No commit, push or
deployment was performed.
@@ -0,0 +1,362 @@
# 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.
@@ -0,0 +1,416 @@
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
@@ -0,0 +1,3 @@
14d3efbe22ba3f36032d137181bbc525ccd56d8f957cb919d687d0eacce48cbc stack/api/README.md
66f76782b3448e32e69803982f4cb294f498afbee4d9495eeee81cff88482f26 stack/api/setspark_api/service.py
552cac8c1b90eade48188482be2931dd82ae1ab8140457195f99481968129356 tools/tests/test_setspark_api.py
+2
View File
@@ -412,3 +412,5 @@ are never rewritten or removed; corrections are new entries.
2026-09-26T23:13:44Z | Sage (T3 Claude Code, thread 1ef1e4f8) | SetSpark approver owner, CHAT-03 charter | Jason delegated both. SetSpark fix: Sage subagent in shared-signals, Rocko reviews, Sage commits; compatibility stop on pending markers resolved as lead decision 21. CHAT-03 brief assigned to Dewey (decision 22). A1/A2 split recorded as decision 20. QUEUE row 5 updated.
2026-09-26T23:25:08Z | Filbert (T3 Claude Code, thread 9cb9731e) | Queue A1 (#1508) code review, manifest 4319695a | changes requested: R1 matrix refusal tests (8.14), R2 review.issue null freeze, R3 evidence round; five Sage conditions met; review agents/filbert/work/queue-a1-review-2026-09-26.md 6933b885
2026-09-26T23:25:52Z | Sage (T3 Claude Code, thread 1ef1e4f8) | #1508 queue A1 review rulings | Filbert changes requested (6933b885), five conditions met. Ruled review issue (explicit --issue when several) and set piece/gate privileged only, lead decision 23; N13 to DEFERRED and Darkwing.
2026-09-26T23:37:23Z | Filbert (T3 Claude Code, thread 9cb9731e) | CHAT-03 brief R1 review (#1507 row 5), BRIEF.md 5dd447f7 | changes requested: B1 R3-1 premise wrong for Pi 0.85.1, B2 foreign-writer detection has no entry ids, B3 schema gaps (unavailable event, turnProof queue text), B4 no live-root refusal, B5 increment order and done gate, B6 claim two-key and reserved gaps; review agents/filbert/work/chat-03-brief-review-2026-09-26.md ec00544e
2026-09-26T23:52:04Z | Sage (T3 Claude Code, thread 1ef1e4f8) | SetSpark approver fix in shared-signals | Sage subagent wrote it, Rocko R1 revise (706e9ac1), R2 approve (c190814b); suites 188/131, vault 45 PASS; committed and pushed shared-signals cc74d92. Deploy to VM 1022 and production survey go to Jason. Lead decision 24.
+14
View File
@@ -279,3 +279,17 @@ which stay with him. Each item names who decided it and what happened.
Filbert's N13 (the nested `node --test` in `test-foundation.sh` and
`test-discord.sh`) is added to the DEFERRED entry. It goes to Darkwing
as a small reviewed item after the A1 delta, before A2.
24. **SetSpark approver fix landed in shared-signals.** Rocko approved R2
(41e735f4, review c190814b) after R1 found that rows stored before the
fix could still open requests, collect approvals, seal and be
superseded. Sage reran the suites on the checkout (188 with a test
database, 131 without, vault 45 PASS) and committed it to shared-signals
main as cc74d92 under the Sage identity, following that repo's
AGENTS.md. It was pushed. Correction to item 21's reasoning about
legacy Accepted rows with more than 16 approvers: I called them unlikely
because sealing one needed more than 16 live approvals. Rocko points out
that the old coordinator import could seal from supplied evidence, so
imported sealed rows need the same survey. The ruling stands: they stay
refused, and a one-off correction goes to Jason if the README query
finds any. Deploying to VM 1022 and running that query on production
are host actions for Jason.
+4
View File
@@ -139,6 +139,10 @@ at every gate. Started 2026-09-12 during the control board MVP.
The rule keeps the sanctioned `pending:` markers. The reasons and the
DEC-009 disposition are in lead decisions item 21. Deploying to VM 1022 is
still a gated host action.
2026-09-26 23:50Z: fixed in source, shared-signals cc74d92 (Rocko
approved R2, lead decision 24). The SetSpark lead confirmed SetSpark
does not depend on free-text approvers. Still open: the deploy to VM 1022
and running the README survey query on production. Both are Jason's.
- **Index-export suite runs leave Docker networks behind.** Each export
directory becomes a compose project, and its `<dir>_default` network
stays after the run. On 2026-09-26 the host ran out of address pools. The