@bongos/core 1.19.228 → 1.19.230
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +44 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +74 -2
- package/docs/api-reference.md +3 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/government/board.js +23 -0
- package/modules/government/db.js +40 -0
- package/modules/government/migrations/government_006_returned_items_index.sql +21 -0
- package/modules/government/module.json +3 -0
- package/modules/government/pollers/board-expiry.js +101 -0
- package/modules/government/routes/government.js +4 -0
- package/modules/ideas/routes/inbox.js +112 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +5 -1
- package/tests/government_board_expiry.mjs +109 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/idea_resubmit.mjs +163 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.230",
|
|
6
|
+
"core_contract": "1.19.230",
|
|
7
|
+
"source_commit": "d0c1085d2633cda96d73c28cdccd40f29771b832",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-18T11:23:52.179Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 311,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1313,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1645,
|
|
20
|
+
"tree_sha256": "6054b1bf932dda1ffc30ce0208aef3e91b62da91beb916a454d51ec6deaf95b0",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -347,12 +347,12 @@
|
|
|
347
347
|
{
|
|
348
348
|
"path": "clients/bongos-client/README.md",
|
|
349
349
|
"mode": "0000644",
|
|
350
|
-
"sha256": "
|
|
350
|
+
"sha256": "ad3ebff5e165c917f14840844032150cacfb40335e29df7f23d51dbb0d475d21"
|
|
351
351
|
},
|
|
352
352
|
{
|
|
353
353
|
"path": "clients/bongos-client/bongos-client.global.js",
|
|
354
354
|
"mode": "0000644",
|
|
355
|
-
"sha256": "
|
|
355
|
+
"sha256": "e474bac9faa5da4da5098b736fd694a9a7ee327d7b952d08a4c366e70e253d84"
|
|
356
356
|
},
|
|
357
357
|
{
|
|
358
358
|
"path": "clients/bongos-client/examples/hello-world.mjs",
|
|
@@ -362,17 +362,17 @@
|
|
|
362
362
|
{
|
|
363
363
|
"path": "clients/bongos-client/index.cjs",
|
|
364
364
|
"mode": "0000644",
|
|
365
|
-
"sha256": "
|
|
365
|
+
"sha256": "b0bd3bbad51ea1b68f7fd36165d77fce12939cde82b37cd2266a00c103002f4b"
|
|
366
366
|
},
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.d.ts",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "7b1983354a89000e6d630d69c896515bac844a2e23a9b8c0da66e6c9b1f386d6"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
374
374
|
"mode": "0000644",
|
|
375
|
-
"sha256": "
|
|
375
|
+
"sha256": "d61a7546173a2212e72c6cbee2ec67b193c3ef506d17ddbfe84e3786fb17cbab"
|
|
376
376
|
},
|
|
377
377
|
{
|
|
378
378
|
"path": "clients/bongos-client/package.json",
|
|
@@ -1372,12 +1372,12 @@
|
|
|
1372
1372
|
{
|
|
1373
1373
|
"path": "docs/api-reference.md",
|
|
1374
1374
|
"mode": "0000644",
|
|
1375
|
-
"sha256": "
|
|
1375
|
+
"sha256": "0f5532248c4c9554c20a6fc11bc60e696fadf996e1d6f7d049553b02614d5a2a"
|
|
1376
1376
|
},
|
|
1377
1377
|
{
|
|
1378
1378
|
"path": "docs/api/openapi.json",
|
|
1379
1379
|
"mode": "0000644",
|
|
1380
|
-
"sha256": "
|
|
1380
|
+
"sha256": "a983685efd08503f73301bf509dda382191aba27321116e064562c8d895ecc73"
|
|
1381
1381
|
},
|
|
1382
1382
|
{
|
|
1383
1383
|
"path": "docs/architecture.md",
|
|
@@ -2687,7 +2687,7 @@
|
|
|
2687
2687
|
{
|
|
2688
2688
|
"path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
|
|
2689
2689
|
"mode": "0000644",
|
|
2690
|
-
"sha256": "
|
|
2690
|
+
"sha256": "b0bd3bbad51ea1b68f7fd36165d77fce12939cde82b37cd2266a00c103002f4b"
|
|
2691
2691
|
},
|
|
2692
2692
|
{
|
|
2693
2693
|
"path": "modules/dev-box/app/test/unit.mjs",
|
|
@@ -2902,7 +2902,7 @@
|
|
|
2902
2902
|
{
|
|
2903
2903
|
"path": "modules/government/board.js",
|
|
2904
2904
|
"mode": "0000644",
|
|
2905
|
-
"sha256": "
|
|
2905
|
+
"sha256": "5db387128d2f7a1ebc15fefaf1e97aabeda9196df34c91bdeb4a12e9301087f0"
|
|
2906
2906
|
},
|
|
2907
2907
|
{
|
|
2908
2908
|
"path": "modules/government/catalog.js",
|
|
@@ -2917,7 +2917,7 @@
|
|
|
2917
2917
|
{
|
|
2918
2918
|
"path": "modules/government/db.js",
|
|
2919
2919
|
"mode": "0000644",
|
|
2920
|
-
"sha256": "
|
|
2920
|
+
"sha256": "e4ff648d5a9d83048214ff111768e5a2ae5e254f4b0a2f8f2fc966ce04138e21"
|
|
2921
2921
|
},
|
|
2922
2922
|
{
|
|
2923
2923
|
"path": "modules/government/migrations/governance_001_tables.sql",
|
|
@@ -2979,10 +2979,20 @@
|
|
|
2979
2979
|
"mode": "0000644",
|
|
2980
2980
|
"sha256": "dc570f8871b578a8af965ffbc91fc4e195f2e7fce7d7886166f0025ac2b47bce"
|
|
2981
2981
|
},
|
|
2982
|
+
{
|
|
2983
|
+
"path": "modules/government/migrations/government_006_returned_items_index.sql",
|
|
2984
|
+
"mode": "0000644",
|
|
2985
|
+
"sha256": "b2c06a954e93487f7df85eb7333c65284e364499fb96c31aad30a263c36b8f73"
|
|
2986
|
+
},
|
|
2982
2987
|
{
|
|
2983
2988
|
"path": "modules/government/module.json",
|
|
2984
2989
|
"mode": "0000644",
|
|
2985
|
-
"sha256": "
|
|
2990
|
+
"sha256": "7baa08a671982532d9961164ffdb37b56bdf11bc8ba133739d3af07b54b0eaa8"
|
|
2991
|
+
},
|
|
2992
|
+
{
|
|
2993
|
+
"path": "modules/government/pollers/board-expiry.js",
|
|
2994
|
+
"mode": "0000644",
|
|
2995
|
+
"sha256": "0e524bee731d9a6120cb98a23f419a2803f9bb19118f5e5aa46befd8e253e9ca"
|
|
2986
2996
|
},
|
|
2987
2997
|
{
|
|
2988
2998
|
"path": "modules/government/protected-surfaces.js",
|
|
@@ -3007,7 +3017,7 @@
|
|
|
3007
3017
|
{
|
|
3008
3018
|
"path": "modules/government/routes/government.js",
|
|
3009
3019
|
"mode": "0000644",
|
|
3010
|
-
"sha256": "
|
|
3020
|
+
"sha256": "34ff36c8ab99797a51b44cceacea2be00d73a5d2a1750d71d815a7c9111b1a3a"
|
|
3011
3021
|
},
|
|
3012
3022
|
{
|
|
3013
3023
|
"path": "modules/government/routes/rank-sync.js",
|
|
@@ -3547,7 +3557,7 @@
|
|
|
3547
3557
|
{
|
|
3548
3558
|
"path": "modules/ideas/routes/inbox.js",
|
|
3549
3559
|
"mode": "0000644",
|
|
3550
|
-
"sha256": "
|
|
3560
|
+
"sha256": "f19fa968ad6fd08b65931039ecf3b8d4b31b1aabef56b42f62cd1a4fbd06328e"
|
|
3551
3561
|
},
|
|
3552
3562
|
{
|
|
3553
3563
|
"path": "modules/ideas/templates.js",
|
|
@@ -4302,12 +4312,12 @@
|
|
|
4302
4312
|
{
|
|
4303
4313
|
"path": "package-lock.json",
|
|
4304
4314
|
"mode": "0000644",
|
|
4305
|
-
"sha256": "
|
|
4315
|
+
"sha256": "ed784184ada68e6661376d541de1491914ae688e18213b306a6315366b571c7d"
|
|
4306
4316
|
},
|
|
4307
4317
|
{
|
|
4308
4318
|
"path": "package.json",
|
|
4309
4319
|
"mode": "0000644",
|
|
4310
|
-
"sha256": "
|
|
4320
|
+
"sha256": "eb8fe0fd9cd657bba4b693eba9c949bc1bec7b64b0690a63f95e510afdf5693c"
|
|
4311
4321
|
},
|
|
4312
4322
|
{
|
|
4313
4323
|
"path": "public-docs/index.html",
|
|
@@ -5922,7 +5932,7 @@
|
|
|
5922
5932
|
{
|
|
5923
5933
|
"path": "src/module-api.js",
|
|
5924
5934
|
"mode": "0000644",
|
|
5925
|
-
"sha256": "
|
|
5935
|
+
"sha256": "a15862c3769ab8efd7c3e87f535df17f692fe31d24476a5cef22be8d890e7a16"
|
|
5926
5936
|
},
|
|
5927
5937
|
{
|
|
5928
5938
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6849,6 +6859,11 @@
|
|
|
6849
6859
|
"mode": "0000644",
|
|
6850
6860
|
"sha256": "4408ec434f36b70a589b0592814733d3f88d48aa9aff38c08c9a2eb6c6c9f7b3"
|
|
6851
6861
|
},
|
|
6862
|
+
{
|
|
6863
|
+
"path": "tests/government_board_expiry.mjs",
|
|
6864
|
+
"mode": "0000644",
|
|
6865
|
+
"sha256": "1bf22bc4ae339459e28a9f4e7975aac9e9d84eacd3f901d30371b44574f714c1"
|
|
6866
|
+
},
|
|
6852
6867
|
{
|
|
6853
6868
|
"path": "tests/government_board_membership.mjs",
|
|
6854
6869
|
"mode": "0000644",
|
|
@@ -6942,7 +6957,7 @@
|
|
|
6942
6957
|
{
|
|
6943
6958
|
"path": "tests/government_seed.mjs",
|
|
6944
6959
|
"mode": "0000644",
|
|
6945
|
-
"sha256": "
|
|
6960
|
+
"sha256": "94e10e6d635ee8a4b16b33559bdfa30b7112bcc4a1fcfa59c30ee3648278f6a4"
|
|
6946
6961
|
},
|
|
6947
6962
|
{
|
|
6948
6963
|
"path": "tests/government_session_scope.mjs",
|
|
@@ -7104,6 +7119,11 @@
|
|
|
7104
7119
|
"mode": "0000644",
|
|
7105
7120
|
"sha256": "729c511f9bb3d4a9b7fb76c7b3843e1900c8d4c9cfa30fe08e482d1e41113839"
|
|
7106
7121
|
},
|
|
7122
|
+
{
|
|
7123
|
+
"path": "tests/idea_resubmit.mjs",
|
|
7124
|
+
"mode": "0000644",
|
|
7125
|
+
"sha256": "51e1286235cbde1b56188f29b0e2bd12ac73a9637dbf1c16b325f10147256f27"
|
|
7126
|
+
},
|
|
7107
7127
|
{
|
|
7108
7128
|
"path": "tests/idea_task_lineage.mjs",
|
|
7109
7129
|
"mode": "0000644",
|
|
@@ -5,7 +5,7 @@ A **generated**, zero-dependency typed client for the Bongos API — produced fr
|
|
|
5
5
|
by hand; it regenerates when the spec changes, so it can never drift from the routes.
|
|
6
6
|
|
|
7
7
|
- API version: **v1** (served at `/api/bongos/v1`)
|
|
8
|
-
-
|
|
8
|
+
- 282 operations across 46 resource groups
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -383,6 +383,8 @@ function createClient(opts = {}) {
|
|
|
383
383
|
patchInboxId: (args) => request("PATCH", "/inbox/{id}", { hasBody: true }, args),
|
|
384
384
|
// POST /inbox/{id}/ratify — rank: metic+archon — POST /inbox/:id/ratify
|
|
385
385
|
postInboxIdRatify: (args) => request("POST", "/inbox/{id}/ratify", { hasBody: true }, args),
|
|
386
|
+
// POST /inbox/{id}/resubmit — rank: any-builder — POST /inbox/:id/resubmit
|
|
387
|
+
postInboxIdResubmit: (args) => request("POST", "/inbox/{id}/resubmit", { hasBody: true }, args),
|
|
386
388
|
},
|
|
387
389
|
"instance": {
|
|
388
390
|
// GET /instance — rank: public — GET /instance
|
|
@@ -382,6 +382,8 @@ function createClient(opts = {}) {
|
|
|
382
382
|
patchInboxId: (args) => request("PATCH", "/inbox/{id}", { hasBody: true }, args),
|
|
383
383
|
// POST /inbox/{id}/ratify — rank: metic+archon — POST /inbox/:id/ratify
|
|
384
384
|
postInboxIdRatify: (args) => request("POST", "/inbox/{id}/ratify", { hasBody: true }, args),
|
|
385
|
+
// POST /inbox/{id}/resubmit — rank: any-builder — POST /inbox/:id/resubmit
|
|
386
|
+
postInboxIdResubmit: (args) => request("POST", "/inbox/{id}/resubmit", { hasBody: true }, args),
|
|
385
387
|
},
|
|
386
388
|
"instance": {
|
|
387
389
|
// GET /instance — rank: public — GET /instance
|
|
@@ -237,6 +237,7 @@ export interface PostGovernmentRanksRankKeyResetResponse { ok: boolean; rank_key
|
|
|
237
237
|
export interface PostGovernmentRanksRequest { rank_key: string; label: string; description?: string }
|
|
238
238
|
export interface PostGovernmentRanksResponse { rank: unknown }
|
|
239
239
|
export interface PostInboxIdRatifyRequest { notes_md?: string }
|
|
240
|
+
export interface PostInboxIdResubmitRequest { field_values: Record<string, unknown>; template_key?: string }
|
|
240
241
|
export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number; grade?: string; template_key?: string; field_values?: Record<string, unknown> }
|
|
241
242
|
export interface PostLearningsRequest { title: string; body_md: string; tags?: string[]; related_files?: string[]; source?: string; source_ref?: string; task_id?: number }
|
|
242
243
|
export interface PostLearningsResponse { skipped?: boolean; reason?: unknown; learning?: unknown }
|
|
@@ -634,6 +635,8 @@ export interface BongosClient {
|
|
|
634
635
|
patchInboxId(args?: RequestArgs & { body?: PatchInboxIdRequest }): Promise<PatchInboxIdResponse>;
|
|
635
636
|
/** POST /inbox/{id}/ratify — rank: metic+archon */
|
|
636
637
|
postInboxIdRatify(args?: RequestArgs & { body?: PostInboxIdRatifyRequest }): Promise<ApiResponse>;
|
|
638
|
+
/** POST /inbox/{id}/resubmit — rank: any-builder */
|
|
639
|
+
postInboxIdResubmit(args: RequestArgs & { body: PostInboxIdResubmitRequest }): Promise<ApiResponse>;
|
|
637
640
|
};
|
|
638
641
|
"instance": {
|
|
639
642
|
/** GET /instance — rank: public */
|
|
@@ -379,6 +379,8 @@ export function createClient(opts = {}) {
|
|
|
379
379
|
patchInboxId: (args) => request("PATCH", "/inbox/{id}", { hasBody: true }, args),
|
|
380
380
|
// POST /inbox/{id}/ratify — rank: metic+archon — POST /inbox/:id/ratify
|
|
381
381
|
postInboxIdRatify: (args) => request("POST", "/inbox/{id}/ratify", { hasBody: true }, args),
|
|
382
|
+
// POST /inbox/{id}/resubmit — rank: any-builder — POST /inbox/:id/resubmit
|
|
383
|
+
postInboxIdResubmit: (args) => request("POST", "/inbox/{id}/resubmit", { hasBody: true }, args),
|
|
382
384
|
},
|
|
383
385
|
"instance": {
|
|
384
386
|
// GET /instance — rank: public — GET /instance
|
package/docs/api/openapi.json
CHANGED
|
@@ -6030,6 +6030,62 @@
|
|
|
6030
6030
|
]
|
|
6031
6031
|
}
|
|
6032
6032
|
},
|
|
6033
|
+
"/inbox/{id}/resubmit": {
|
|
6034
|
+
"post": {
|
|
6035
|
+
"operationId": "post_inbox_id_resubmit",
|
|
6036
|
+
"tags": [
|
|
6037
|
+
"inbox"
|
|
6038
|
+
],
|
|
6039
|
+
"summary": "POST /inbox/:id/resubmit",
|
|
6040
|
+
"description": "RESUBMIT a Full Idea after a rejection (R13 of goal 1000069, criterion C2: an idea never dies and never stalls). AUTHOR-ONLY, enforced in-handler against captured_by: the rejection went back to the originator, and only they can answer it — the same ownership posture as marking a spark (R144). Unbounded by design (the grade gate bounds spam), and every past rejection stays visible: the closed board items and their objection votes are permanent rows, and this writes a NEW grade + opens a NEW window rather than editing history. THE FEEDBACK LOOP, both rejection sources, one shape: • a board return → the objection (reason + flagged section) off the most recent returned item, via the government port; • a sub-bar grade → the weakest-section flag, recomputed from the OLD field_values (derived, never stored — R09). The response says whether the FLAGGED SECTION actually changed, computed per-field exactly because completeness.js scores per-field: a resubmission that reworked everything EXCEPT what was objected to is told so plainly.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
6041
|
+
"x-rank": "any-builder",
|
|
6042
|
+
"x-source": "modules/ideas/routes/inbox.js",
|
|
6043
|
+
"parameters": [
|
|
6044
|
+
{
|
|
6045
|
+
"name": "id",
|
|
6046
|
+
"in": "path",
|
|
6047
|
+
"required": true,
|
|
6048
|
+
"schema": {
|
|
6049
|
+
"type": "string"
|
|
6050
|
+
},
|
|
6051
|
+
"description": "Path parameter `id`."
|
|
6052
|
+
}
|
|
6053
|
+
],
|
|
6054
|
+
"requestBody": {
|
|
6055
|
+
"required": true,
|
|
6056
|
+
"content": {
|
|
6057
|
+
"application/json": {
|
|
6058
|
+
"schema": {
|
|
6059
|
+
"$ref": "#/components/schemas/PostInboxIdResubmitRequest"
|
|
6060
|
+
}
|
|
6061
|
+
}
|
|
6062
|
+
},
|
|
6063
|
+
"x-validated": true
|
|
6064
|
+
},
|
|
6065
|
+
"responses": {
|
|
6066
|
+
"200": {
|
|
6067
|
+
"description": "Success."
|
|
6068
|
+
},
|
|
6069
|
+
"400": {
|
|
6070
|
+
"$ref": "#/components/responses/ValidationFailed"
|
|
6071
|
+
},
|
|
6072
|
+
"401": {
|
|
6073
|
+
"$ref": "#/components/responses/Unauthorized"
|
|
6074
|
+
},
|
|
6075
|
+
"403": {
|
|
6076
|
+
"$ref": "#/components/responses/Forbidden"
|
|
6077
|
+
},
|
|
6078
|
+
"404": {
|
|
6079
|
+
"$ref": "#/components/responses/NotFound"
|
|
6080
|
+
}
|
|
6081
|
+
},
|
|
6082
|
+
"security": [
|
|
6083
|
+
{
|
|
6084
|
+
"builderSession": []
|
|
6085
|
+
}
|
|
6086
|
+
]
|
|
6087
|
+
}
|
|
6088
|
+
},
|
|
6033
6089
|
"/instance": {
|
|
6034
6090
|
"get": {
|
|
6035
6091
|
"operationId": "get_instance",
|
|
@@ -16102,6 +16158,22 @@
|
|
|
16102
16158
|
},
|
|
16103
16159
|
"additionalProperties": false
|
|
16104
16160
|
},
|
|
16161
|
+
"PostInboxIdResubmitRequest": {
|
|
16162
|
+
"type": "object",
|
|
16163
|
+
"properties": {
|
|
16164
|
+
"field_values": {
|
|
16165
|
+
"type": "object"
|
|
16166
|
+
},
|
|
16167
|
+
"template_key": {
|
|
16168
|
+
"type": "string",
|
|
16169
|
+
"maxLength": 64
|
|
16170
|
+
}
|
|
16171
|
+
},
|
|
16172
|
+
"required": [
|
|
16173
|
+
"field_values"
|
|
16174
|
+
],
|
|
16175
|
+
"additionalProperties": false
|
|
16176
|
+
},
|
|
16105
16177
|
"PostInboxRequest": {
|
|
16106
16178
|
"type": "object",
|
|
16107
16179
|
"properties": {
|
|
@@ -17809,8 +17881,8 @@
|
|
|
17809
17881
|
"description": "A required dependency/feature is not configured or is temporarily down."
|
|
17810
17882
|
}
|
|
17811
17883
|
},
|
|
17812
|
-
"x-endpoint-count":
|
|
17813
|
-
"x-schema-count":
|
|
17884
|
+
"x-endpoint-count": 282,
|
|
17885
|
+
"x-schema-count": 319,
|
|
17814
17886
|
"x-undocumented-bodies": 8,
|
|
17815
17887
|
"x-response-schemas": 214,
|
|
17816
17888
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
package/docs/api-reference.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Bongos API reference
|
|
4
4
|
|
|
5
|
-
> **Generated from the live route files** — the route file is authoritative.
|
|
5
|
+
> **Generated from the live route files** — the route file is authoritative. 282 endpoints across 46 route files.
|
|
6
6
|
> Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
|
|
7
7
|
|
|
8
8
|
Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
|
|
@@ -243,7 +243,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
243
243
|
|---|---|---|---|---|
|
|
244
244
|
| GET | `/api/bongos/healthz` | `public` | — | liveness probe; status.example.com hits this anonymously. |
|
|
245
245
|
|
|
246
|
-
## `inbox` (
|
|
246
|
+
## `inbox` (6)
|
|
247
247
|
|
|
248
248
|
| Method | Path | Rank | Body | Description |
|
|
249
249
|
|---|---|---|---|---|
|
|
@@ -252,6 +252,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
252
252
|
| GET | `/api/bongos/inbox/:id` | `any-builder` | — | single idea read for the "#NNN" deep-link resolver (task 1047). |
|
|
253
253
|
| PATCH | `/api/bongos/inbox/:id` | `metic+archon` | `status`, `kind`, `promoted_to_task_id`, `reviewed_at`, `merged_into_idea_id`, `triage_note` | Metic+ (task 360 / ADR 0018): idea triage (promote/discard/merge) shapes the backlog. |
|
|
254
254
|
| POST | `/api/bongos/inbox/:id/ratify` | `metic+archon` | `notes_md` | V3.R31 (task 252) — Archon ratifies a criterion-proposal idea, atomically: - INSERT done_when_criteria row on the target version - UPDATE… |
|
|
255
|
+
| POST | `/api/bongos/inbox/:id/resubmit` | `any-builder` | `field_values`, `template_key` | RESUBMIT a Full Idea after a rejection (R13 of goal 1000069, criterion C2: an idea never dies and never stalls). |
|
|
255
256
|
|
|
256
257
|
## `instance` (1)
|
|
257
258
|
|
|
@@ -382,6 +382,8 @@ function createClient(opts = {}) {
|
|
|
382
382
|
patchInboxId: (args) => request("PATCH", "/inbox/{id}", { hasBody: true }, args),
|
|
383
383
|
// POST /inbox/{id}/ratify — rank: metic+archon — POST /inbox/:id/ratify
|
|
384
384
|
postInboxIdRatify: (args) => request("POST", "/inbox/{id}/ratify", { hasBody: true }, args),
|
|
385
|
+
// POST /inbox/{id}/resubmit — rank: any-builder — POST /inbox/:id/resubmit
|
|
386
|
+
postInboxIdResubmit: (args) => request("POST", "/inbox/{id}/resubmit", { hasBody: true }, args),
|
|
385
387
|
},
|
|
386
388
|
"instance": {
|
|
387
389
|
// GET /instance — rank: public — GET /instance
|
|
@@ -351,6 +351,28 @@ async function maybeCloseAfterVote(itemId) {
|
|
|
351
351
|
return closeItem(item, { cause: 'vote' });
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
+
// ── R13's read: the feedback a returned idea carries back to its author ─────
|
|
355
|
+
//
|
|
356
|
+
// One reasoned objection is what returns an idea (§3), so the feedback IS that
|
|
357
|
+
// vote's reason + flagged section, straight off the most recent returned item.
|
|
358
|
+
// Null when this idea has never been returned by the board — the caller (the
|
|
359
|
+
// ideas resubmit path) falls back to the sub-bar weakest-section flag, which is
|
|
360
|
+
// derivable from the stored field_values at any time (R09). Shape mirrors the
|
|
361
|
+
// vote payload so the return flow renders ONE thing for both rejection sources.
|
|
362
|
+
async function latestRejectionForIdea(ideaId) {
|
|
363
|
+
const id = Number(ideaId);
|
|
364
|
+
if (!Number.isFinite(id) || id <= 0) return null;
|
|
365
|
+
const row = await db.getLatestReturnedItemWithObjection('full_idea', id);
|
|
366
|
+
if (!row) return null;
|
|
367
|
+
return Object.freeze({
|
|
368
|
+
source: 'board',
|
|
369
|
+
item_id: row.item_id,
|
|
370
|
+
section_key: row.section_key || null,
|
|
371
|
+
reason_md: row.reason_md || null,
|
|
372
|
+
closed_at: row.closed_at,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
354
376
|
module.exports = {
|
|
355
377
|
FULL_IDEA_PASS_BAR,
|
|
356
378
|
ideaPassesBar,
|
|
@@ -366,4 +388,5 @@ module.exports = {
|
|
|
366
388
|
passPayload,
|
|
367
389
|
closeItem,
|
|
368
390
|
maybeCloseAfterVote,
|
|
391
|
+
latestRejectionForIdea,
|
|
369
392
|
};
|
package/modules/government/db.js
CHANGED
|
@@ -437,6 +437,44 @@ async function getIdeaCompleteness(ideaId) {
|
|
|
437
437
|
return rows[0] ? rows[0].completeness_score : null;
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
+
// R13's read: the most recent RETURNED item for a subject, carrying its
|
|
441
|
+
// objection (the single 'no' vote — one reasoned objection is what returns an
|
|
442
|
+
// idea, ADR 0175 §3). LEFT JOIN because a returned-by-expiry item could in
|
|
443
|
+
// principle carry no objection row; the caller falls back to the sub-bar flag.
|
|
444
|
+
async function getLatestReturnedItemWithObjection(subjectType, subjectId) {
|
|
445
|
+
// Ordering is doubly deterministic: the LATEST returned item, then that
|
|
446
|
+
// item's FIRST objection (v.cast_at, id as the tiebreak). Under consent
|
|
447
|
+
// several members can object before the sitting ends; the first objection is
|
|
448
|
+
// the one that decided the outcome, so it is the feedback the author answers.
|
|
449
|
+
// Indexed by government_006's returned-items index — this runs on every
|
|
450
|
+
// resubmit, and the items table only grows.
|
|
451
|
+
const { rows } = await pool.query(
|
|
452
|
+
`SELECT i.id AS item_id, i.closed_at, v.reason_md, v.section_key, v.voter_id
|
|
453
|
+
FROM government_board_items i
|
|
454
|
+
LEFT JOIN government_board_votes v ON v.item_id = i.id AND v.direction = 'no'
|
|
455
|
+
WHERE i.subject_type = $1 AND i.subject_id = $2 AND i.outcome = 'returned'
|
|
456
|
+
ORDER BY i.closed_at DESC, v.cast_at ASC, v.id ASC
|
|
457
|
+
LIMIT 1`,
|
|
458
|
+
[subjectType, subjectId],
|
|
459
|
+
);
|
|
460
|
+
return rows[0] || null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// The expiry sweep's read (R11): every OPEN item whose deadline has passed, on
|
|
464
|
+
// the database clock, oldest deadline first. Served by the partial index
|
|
465
|
+
// government_board_items_open_by_deadline (government_003 built it for exactly
|
|
466
|
+
// this). NULL closes_at — the no-clock configuration — never appears here.
|
|
467
|
+
async function getExpiredOpenBoardItems(limit = 100) {
|
|
468
|
+
const { rows } = await pool.query(
|
|
469
|
+
`SELECT * FROM government_board_items
|
|
470
|
+
WHERE closed_at IS NULL AND closes_at IS NOT NULL AND closes_at < now()
|
|
471
|
+
ORDER BY closes_at
|
|
472
|
+
LIMIT $1`,
|
|
473
|
+
[limit],
|
|
474
|
+
);
|
|
475
|
+
return rows;
|
|
476
|
+
}
|
|
477
|
+
|
|
440
478
|
// Author-ratification karma (+3, board.js AUTHOR_RATIFY_KARMA_DELTA) — fired by
|
|
441
479
|
// the R10 close on a passed item. IDEMPOTENT: the reason key is unique per item
|
|
442
480
|
// and the INSERT is one atomic WHERE-NOT-EXISTS statement (the economy
|
|
@@ -491,4 +529,6 @@ module.exports = {
|
|
|
491
529
|
getVotesForItem,
|
|
492
530
|
closeBoardItem,
|
|
493
531
|
getIdeaCompleteness,
|
|
532
|
+
getExpiredOpenBoardItems,
|
|
533
|
+
getLatestReturnedItemWithObjection,
|
|
494
534
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- government_006_returned_items_index.sql — index the returned-item lookup
|
|
2
|
+
-- (task 1003026 / R13 of goal 1000069; a grader efficiency finding, and right).
|
|
3
|
+
--
|
|
4
|
+
-- getLatestReturnedItemWithObjection runs on EVERY resubmit: "the most recent
|
|
5
|
+
-- RETURNED item for this subject". government_board_items only grows — one row
|
|
6
|
+
-- per window, resubmission is unbounded by design — and the table's two
|
|
7
|
+
-- existing partial indexes both cover OPEN items (closed_at IS NULL), so this
|
|
8
|
+
-- closed-row lookup would otherwise scan historical volume that scales with
|
|
9
|
+
-- every sitting the project has ever held.
|
|
10
|
+
--
|
|
11
|
+
-- Partial on outcome='returned' (the only outcome the read asks for), keyed to
|
|
12
|
+
-- match the query's WHERE + ORDER BY exactly. Additive, idempotent, no rows
|
|
13
|
+
-- touched — safe on any database state.
|
|
14
|
+
|
|
15
|
+
BEGIN;
|
|
16
|
+
|
|
17
|
+
CREATE INDEX IF NOT EXISTS government_board_items_returned_by_subject
|
|
18
|
+
ON government_board_items (subject_type, subject_id, closed_at DESC)
|
|
19
|
+
WHERE outcome = 'returned';
|
|
20
|
+
|
|
21
|
+
COMMIT;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/government/pollers/board-expiry.js — the window expiry sweep
|
|
4
|
+
// (BV1.R11 of goal 1000069, ADR 0175 §2).
|
|
5
|
+
//
|
|
6
|
+
// A window closes ON TIME with nobody touching it. The clock is the sitting
|
|
7
|
+
// model's deadlock exit — absent members drop out of the denominator and the
|
|
8
|
+
// deadline ends the sitting — so this poller is what makes the timed half of
|
|
9
|
+
// the constitution real. It calls the SAME closeItem the post-vote path calls
|
|
10
|
+
// (R10), so a clock-close and a turnout-close can never mean different things,
|
|
11
|
+
// and the single-winner UPDATE makes racing one of them against a landing vote
|
|
12
|
+
// safe: exactly one outcome is written.
|
|
13
|
+
//
|
|
14
|
+
// THE SWEEP SHAPE follows the publish reconciler (the in-tree precedent the
|
|
15
|
+
// spec names): a bounded per-sweep read, PER-ITEM failure isolation (one item
|
|
16
|
+
// that cannot close must not stall the item behind it), and a per-item
|
|
17
|
+
// DISPOSITION LINE so a stuck window is visible in the journal rather than
|
|
18
|
+
// silent. Unlike the reconciler it stays QUIET on an empty sweep — a
|
|
19
|
+
// minute-cadence poller logging every tick would bury the journal (the
|
|
20
|
+
// reconciler's own warn-once lesson).
|
|
21
|
+
//
|
|
22
|
+
// CADENCE: windows are minutes-granular (the recommended sitting is 90
|
|
23
|
+
// minutes), so a one-minute sweep bounds lateness at ~1 minute — tight enough
|
|
24
|
+
// that "closes on time" is honest, cheap enough that the query (one indexed
|
|
25
|
+
// read; government_board_items_open_by_deadline exists for exactly this, per
|
|
26
|
+
// government_003's own comment) is negligible. Concurrent windows are normal
|
|
27
|
+
// (owner decision 10): the sweep closes every due item, oldest deadline first.
|
|
28
|
+
|
|
29
|
+
const board = require('../board');
|
|
30
|
+
const db = require('../db');
|
|
31
|
+
|
|
32
|
+
const INTERVAL_MS = Number(process.env.BOARD_EXPIRY_SWEEP_MS) || 60_000;
|
|
33
|
+
// A first sweep shortly after boot, so windows that expired while the process
|
|
34
|
+
// was down (a deploy restart mid-sitting) close promptly rather than one full
|
|
35
|
+
// interval late.
|
|
36
|
+
const FIRST_SWEEP_DELAY_MS = Number(process.env.BOARD_EXPIRY_FIRST_MS) || 15_000;
|
|
37
|
+
// Runaway guard, not an expected bound — a project rarely has more than a
|
|
38
|
+
// handful of open windows (the board's subjects are rare by construction).
|
|
39
|
+
const SWEEP_LIMIT = 100;
|
|
40
|
+
|
|
41
|
+
let intervalTimer = null;
|
|
42
|
+
let firstTimer = null;
|
|
43
|
+
|
|
44
|
+
// One sweep. Injectable deps keep it DB-free testable: listFn yields the due
|
|
45
|
+
// items, closeFn closes one, log receives the disposition lines.
|
|
46
|
+
async function runSweep({ listFn = db.getExpiredOpenBoardItems, closeFn = board.closeItem, log = console.log, error = console.error } = {}) {
|
|
47
|
+
let due;
|
|
48
|
+
try {
|
|
49
|
+
due = await listFn(SWEEP_LIMIT);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
error(`[board-expiry] sweep read failed: ${e && e.message}`);
|
|
52
|
+
return { closed: 0, failed: 0, due: 0 };
|
|
53
|
+
}
|
|
54
|
+
if (!due || !due.length) return { closed: 0, failed: 0, due: 0 }; // quiet on empty
|
|
55
|
+
let closed = 0;
|
|
56
|
+
let failed = 0;
|
|
57
|
+
for (const item of due) {
|
|
58
|
+
try {
|
|
59
|
+
const out = await closeFn(item, { cause: 'expiry' });
|
|
60
|
+
if (out && out.closed) {
|
|
61
|
+
closed += 1;
|
|
62
|
+
log(`[board-expiry] item ${item.id} (${item.subject_type} ${item.subject_id}) expired → ${out.outcome}`);
|
|
63
|
+
} else {
|
|
64
|
+
// A racer (a landing vote's close) beat the sweep — the item is closed,
|
|
65
|
+
// just not by us. That is the single-winner design working; say so.
|
|
66
|
+
log(`[board-expiry] item ${item.id} already closed by a racer — nothing to do`);
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
failed += 1;
|
|
70
|
+
// Per-item isolation: name the item and keep sweeping. A close that
|
|
71
|
+
// throws here (e.g. the in-tx credit failing) has ROLLED BACK, so the
|
|
72
|
+
// item is still open and the next sweep retries it — the pass-event
|
|
73
|
+
// contract's "retried, never silently unpaid".
|
|
74
|
+
error(`[board-expiry] item ${item.id} failed to close (still open; next sweep retries): ${e && e.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { closed, failed, due: due.length };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function start(deps = {}) {
|
|
81
|
+
if (process.env.BOARD_EXPIRY_DISABLED === '1') {
|
|
82
|
+
console.log('[board-expiry] disabled (BOARD_EXPIRY_DISABLED=1)');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (intervalTimer) return; // already started
|
|
86
|
+
const tick = () => {
|
|
87
|
+
runSweep(deps).catch((e) => console.error(`[board-expiry] sweep crashed: ${e && e.message}`));
|
|
88
|
+
};
|
|
89
|
+
firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
|
|
90
|
+
if (firstTimer.unref) firstTimer.unref();
|
|
91
|
+
intervalTimer = setInterval(tick, INTERVAL_MS);
|
|
92
|
+
if (intervalTimer.unref) intervalTimer.unref(); // never hold the process open on a restart
|
|
93
|
+
console.log(`[board-expiry] started (every ${Math.round(INTERVAL_MS / 1000)}s; first sweep in ${Math.round(FIRST_SWEEP_DELAY_MS / 1000)}s)`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stop() {
|
|
97
|
+
if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
|
|
98
|
+
if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { start, stop, runSweep, INTERVAL_MS, SWEEP_LIMIT };
|
|
@@ -91,6 +91,10 @@ const governmentPort = {
|
|
|
91
91
|
// bar gate lives INSIDE board.js (a sub-bar idea never reaches the board, and
|
|
92
92
|
// no caller is trusted to pre-filter). Returns, never throws into capture.
|
|
93
93
|
openBoardWindowForIdea: board.openWindowForFullIdea,
|
|
94
|
+
// R13: the feedback a board-returned idea carries back to its author — the
|
|
95
|
+
// resubmit path in modules/ideas resolves this to show what was objected to
|
|
96
|
+
// and to check whether the flagged section actually changed.
|
|
97
|
+
latestRejectionForIdea: board.latestRejectionForIdea,
|
|
94
98
|
};
|
|
95
99
|
|
|
96
100
|
function registerSeams() {
|
|
@@ -254,6 +254,118 @@ module.exports = function buildInboxRouter() {
|
|
|
254
254
|
}
|
|
255
255
|
});
|
|
256
256
|
|
|
257
|
+
// rank: any-builder — RESUBMIT a Full Idea after a rejection (R13 of goal
|
|
258
|
+
// 1000069, criterion C2: an idea never dies and never stalls). AUTHOR-ONLY,
|
|
259
|
+
// enforced in-handler against captured_by: the rejection went back to the
|
|
260
|
+
// originator, and only they can answer it — the same ownership posture as
|
|
261
|
+
// marking a spark (R144). Unbounded by design (the grade gate bounds spam),
|
|
262
|
+
// and every past rejection stays visible: the closed board items and their
|
|
263
|
+
// objection votes are permanent rows, and this writes a NEW grade + opens a
|
|
264
|
+
// NEW window rather than editing history.
|
|
265
|
+
//
|
|
266
|
+
// THE FEEDBACK LOOP, both rejection sources, one shape:
|
|
267
|
+
// • a board return → the objection (reason + flagged section) off the most
|
|
268
|
+
// recent returned item, via the government port;
|
|
269
|
+
// • a sub-bar grade → the weakest-section flag, recomputed from the OLD
|
|
270
|
+
// field_values (derived, never stored — R09).
|
|
271
|
+
// The response says whether the FLAGGED SECTION actually changed, computed
|
|
272
|
+
// per-field exactly because completeness.js scores per-field: a resubmission
|
|
273
|
+
// that reworked everything EXCEPT what was objected to is told so plainly.
|
|
274
|
+
router.post('/inbox/:id/resubmit', api.requireBuilder, async (req, res) => {
|
|
275
|
+
const id = parseId(req, res, { code: 'bad_id', positiveInt: true });
|
|
276
|
+
if (id === null) return;
|
|
277
|
+
if (validateOrRespond(req, res, {
|
|
278
|
+
field_values: { required: true, type: 'object' },
|
|
279
|
+
template_key: { type: 'string', maxLength: 64 },
|
|
280
|
+
})) return;
|
|
281
|
+
try {
|
|
282
|
+
const idea = await inbox.getIdea(id);
|
|
283
|
+
if (!idea) return res.fail('idea_not_found', 404);
|
|
284
|
+
if (String(idea.captured_by) !== String(req.builder.id)) {
|
|
285
|
+
return res.fail('not_your_idea', 403, { reason: 'a rejection returns to its ORIGINATOR; only they can resubmit' });
|
|
286
|
+
}
|
|
287
|
+
if (idea.grade !== 'full') {
|
|
288
|
+
return res.fail('not_a_full_idea', 409, { grade: idea.grade, reason: 'resubmission is the Full Idea loop; a quick idea has no grade to answer' });
|
|
289
|
+
}
|
|
290
|
+
const templateKey = req.body.template_key ?? idea.template_key ?? undefined;
|
|
291
|
+
let v;
|
|
292
|
+
try {
|
|
293
|
+
v = validateFieldValues(req.body.field_values, templateKey ? { templateKey } : undefined);
|
|
294
|
+
} catch (validationErr) {
|
|
295
|
+
console.error('[gds] POST /inbox/:id/resubmit validation threw:', validationErr);
|
|
296
|
+
return res.fail('invalid_field_values', 400, { errors: [{ field: null, reason: 'unvalidatable' }] });
|
|
297
|
+
}
|
|
298
|
+
if (!v.ok) return res.fail('invalid_field_values', 400, { errors: v.errors, template_key: templateKey ?? null });
|
|
299
|
+
|
|
300
|
+
// What was flagged, before the old values are overwritten. Board return
|
|
301
|
+
// first (the human objection outranks the mechanical flag); sub-bar flag
|
|
302
|
+
// as the fallback; null when this idea was never rejected at all (a
|
|
303
|
+
// resubmit is then just a revision — still legal, still rescored).
|
|
304
|
+
const government = api.resolveOptional && api.resolveOptional('government');
|
|
305
|
+
let rejection = null;
|
|
306
|
+
if (government && government.latestRejectionForIdea) {
|
|
307
|
+
try { rejection = await government.latestRejectionForIdea(id); }
|
|
308
|
+
catch (e) { console.error('[gds] resubmit: rejection lookup failed (continuing without):', e); }
|
|
309
|
+
}
|
|
310
|
+
if (!rejection || !rejection.section_key) {
|
|
311
|
+
const flag = weakestSectionPayload(idea.field_values, templateKey ? { templateKey } : undefined);
|
|
312
|
+
if (flag) rejection = { source: 'grade', ...flag };
|
|
313
|
+
}
|
|
314
|
+
const flaggedKey = rejection && rejection.section_key ? rejection.section_key : null;
|
|
315
|
+
const oldFlagged = flaggedKey && idea.field_values ? String(idea.field_values[flaggedKey] ?? '').trim() : null;
|
|
316
|
+
const newFlagged = flaggedKey ? String(req.body.field_values[flaggedKey] ?? '').trim() : null;
|
|
317
|
+
const flaggedSectionChanged = flaggedKey ? oldFlagged !== newFlagged : null;
|
|
318
|
+
|
|
319
|
+
// The whole-row grade write (setIdeaGrade's documented semantics — a
|
|
320
|
+
// partial merge would inherit stale fields, the exact bug its contract
|
|
321
|
+
// names), then the SAME post-grade chain capture runs: the idempotent
|
|
322
|
+
// stream-2 credit (an idea that first passes on resubmission earns it
|
|
323
|
+
// now; an already-paid one books nothing), and the R07 window hook — the
|
|
324
|
+
// prior item is CLOSED, so the partial unique index admits a NEW window.
|
|
325
|
+
const row = await inbox.setIdeaGrade(id, {
|
|
326
|
+
grade: 'full',
|
|
327
|
+
...(templateKey ? { templateKey } : {}),
|
|
328
|
+
fieldValues: req.body.field_values,
|
|
329
|
+
completenessScore: completenessScore(req.body.field_values, templateKey ? { templateKey } : undefined),
|
|
330
|
+
});
|
|
331
|
+
const out = { ...idea, grade: row.grade, template_key: row.template_key, field_values: row.field_values, completeness_score: row.completeness_score };
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const reward = api.resolveOptional && api.resolveOptional('reward');
|
|
335
|
+
if (reward && reward.awardIdeaFullPassCredit) {
|
|
336
|
+
const r = await reward.awardIdeaFullPassCredit(api.pool, { ideaId: id, authorId: req.builder.id, completenessScore: row.completeness_score });
|
|
337
|
+
if (r && r.credited > 0) out.ideator_credit = r.credited;
|
|
338
|
+
}
|
|
339
|
+
} catch (creditErr) {
|
|
340
|
+
console.error('[gds] resubmit: pass-credit failed (regrade intact, credit skipped):', creditErr);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let board_window;
|
|
344
|
+
try {
|
|
345
|
+
if (government && government.openBoardWindowForIdea) {
|
|
346
|
+
const w = await government.openBoardWindowForIdea({ ideaId: id, completenessScore: row.completeness_score });
|
|
347
|
+
if (w && w.opened && w.item) board_window = { item_id: w.item.id, closes_at: w.item.closes_at };
|
|
348
|
+
else if (w && w.reason === 'below_bar') {
|
|
349
|
+
const flag = weakestSectionPayload(req.body.field_values, templateKey ? { templateKey } : undefined);
|
|
350
|
+
if (flag) out.rejection = flag; // still short — the NEW weakest section, fresh feedback
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
} catch (boardErr) {
|
|
354
|
+
console.error('[gds] resubmit: board-window open failed (regrade intact, window skipped):', boardErr);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
res.json({
|
|
358
|
+
idea: out,
|
|
359
|
+
resubmitted: true,
|
|
360
|
+
...(flaggedKey ? { flagged_section: flaggedKey, flagged_section_changed: flaggedSectionChanged, rejection_source: rejection.source || 'board' } : {}),
|
|
361
|
+
...(board_window ? { board_window } : {}),
|
|
362
|
+
});
|
|
363
|
+
} catch (err) {
|
|
364
|
+
console.error('[gds] POST /inbox/:id/resubmit', err);
|
|
365
|
+
res.fail('resubmit_failed', { status: 500, message: 'internal error' });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
257
369
|
// Metic+ (#360 / ADR 0018): idea triage (promote/discard/merge) shapes the
|
|
258
370
|
// backlog. Criterion C2 assigns triage to Metic, so this widened from
|
|
259
371
|
// archon-only to metic+archon when the three-rank model went live. Any
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.230",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.230",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.230",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -883,8 +883,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
883
883
|
// landed since 1.19.226 with no explicit bump. run 32101041951. (task 1002620)
|
|
884
884
|
// 1.19.228 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
885
885
|
// landed since 1.19.227 with no explicit bump. run 32101659669. (task 1002620)
|
|
886
|
+
// 1.19.229 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
887
|
+
// landed since 1.19.228 with no explicit bump. run 32130159805. (task 1002620)
|
|
888
|
+
// 1.19.230 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
889
|
+
// landed since 1.19.229 with no explicit bump. run 32131450528. (task 1002620)
|
|
886
890
|
// ---------------------------------------------------------------------------
|
|
887
|
-
const CORE_VERSION = '1.19.
|
|
891
|
+
const CORE_VERSION = '1.19.230'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
888
892
|
|
|
889
893
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
890
894
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// tests/government_board_expiry.mjs — the window expiry poller (BV1.R11 of
|
|
2
|
+
// goal 1000069, ADR 0175 §2): a window closes on time with nobody touching it.
|
|
3
|
+
//
|
|
4
|
+
// DB-free: runSweep takes injectable deps (the reconciler's testable shape).
|
|
5
|
+
// What must hold:
|
|
6
|
+
// 1. EVERY due item is closed through the SAME closeItem R10 built, with
|
|
7
|
+
// cause 'expiry' — a clock-close and a turnout-close are one path.
|
|
8
|
+
// 2. PER-ITEM ISOLATION — an item that fails to close (rolled back, still
|
|
9
|
+
// open, retried next sweep) must not stall the items behind it.
|
|
10
|
+
// 3. DISPOSITIONS ARE VISIBLE — a closed item and a racer-beaten item each
|
|
11
|
+
// log a line; an EMPTY sweep logs nothing (a minute-cadence poller must
|
|
12
|
+
// not bury the journal).
|
|
13
|
+
// 4. The timers never hold the process open, and stop() is clean.
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
24
|
+
const poller = require(path.join(GOV, 'pollers', 'board-expiry.js'));
|
|
25
|
+
|
|
26
|
+
const item = (id, over = {}) => ({ id: String(id), subject_type: 'full_idea', subject_id: String(id * 10), closed_at: null, ...over });
|
|
27
|
+
|
|
28
|
+
test('every due item closes via closeItem with cause expiry — concurrent windows are normal', async () => {
|
|
29
|
+
const closed = [];
|
|
30
|
+
const lines = [];
|
|
31
|
+
const out = await poller.runSweep({
|
|
32
|
+
listFn: async (limit) => { assert.equal(limit, poller.SWEEP_LIMIT); return [item(1), item(2), item(3)]; },
|
|
33
|
+
closeFn: async (it, opts) => { closed.push([it.id, opts.cause]); return { closed: true, outcome: 'passed' }; },
|
|
34
|
+
log: (l) => lines.push(l),
|
|
35
|
+
error: () => { throw new Error('no errors expected'); },
|
|
36
|
+
});
|
|
37
|
+
assert.deepEqual(closed, [['1', 'expiry'], ['2', 'expiry'], ['3', 'expiry']]);
|
|
38
|
+
assert.deepEqual(out, { closed: 3, failed: 0, due: 3 });
|
|
39
|
+
assert.equal(lines.length, 3, 'one disposition line per closed item');
|
|
40
|
+
assert.ok(lines[0].includes('expired → passed'));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('one failing item does not stall the sweep — named, counted, retried next time', async () => {
|
|
44
|
+
const closed = [];
|
|
45
|
+
const errors = [];
|
|
46
|
+
const out = await poller.runSweep({
|
|
47
|
+
listFn: async () => [item(1), item(2), item(3)],
|
|
48
|
+
closeFn: async (it) => {
|
|
49
|
+
if (it.id === '2') throw new Error('credit write refused — close rolled back');
|
|
50
|
+
closed.push(it.id);
|
|
51
|
+
return { closed: true, outcome: 'returned' };
|
|
52
|
+
},
|
|
53
|
+
log: () => {},
|
|
54
|
+
error: (l) => errors.push(l),
|
|
55
|
+
});
|
|
56
|
+
assert.deepEqual(closed, ['1', '3'], 'the items behind the failure still closed');
|
|
57
|
+
assert.deepEqual(out, { closed: 2, failed: 1, due: 3 });
|
|
58
|
+
assert.equal(errors.length, 1);
|
|
59
|
+
assert.ok(errors[0].includes('item 2') && errors[0].includes('next sweep retries'),
|
|
60
|
+
'the stuck window is visible, and the retry story is stated');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a racer-beaten item is a disposition, not an error — the single-winner design working', async () => {
|
|
64
|
+
const lines = [];
|
|
65
|
+
const out = await poller.runSweep({
|
|
66
|
+
listFn: async () => [item(7)],
|
|
67
|
+
closeFn: async () => ({ closed: false, reason: 'already_closed' }),
|
|
68
|
+
log: (l) => lines.push(l),
|
|
69
|
+
error: () => { throw new Error('not an error'); },
|
|
70
|
+
});
|
|
71
|
+
assert.deepEqual(out, { closed: 0, failed: 0, due: 1 });
|
|
72
|
+
assert.ok(lines[0].includes('already closed by a racer'));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an empty sweep is SILENT, and a failed read is one error line, not a crash', async () => {
|
|
76
|
+
const lines = [];
|
|
77
|
+
const errors = [];
|
|
78
|
+
assert.deepEqual(await poller.runSweep({ listFn: async () => [], log: (l) => lines.push(l), error: (l) => errors.push(l) }),
|
|
79
|
+
{ closed: 0, failed: 0, due: 0 });
|
|
80
|
+
assert.equal(lines.length, 0, 'a minute-cadence poller must not bury the journal');
|
|
81
|
+
assert.deepEqual(await poller.runSweep({ listFn: async () => { throw new Error('db down'); }, log: () => {}, error: (l) => errors.push(l) }),
|
|
82
|
+
{ closed: 0, failed: 0, due: 0 });
|
|
83
|
+
assert.equal(errors.length, 1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the sweep read is indexed, deadline-ordered, and never sees a no-clock item (SQL pins)', () => {
|
|
87
|
+
const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
|
|
88
|
+
const block = src.slice(src.indexOf('async function getExpiredOpenBoardItems'), src.indexOf('// Author-ratification karma'));
|
|
89
|
+
assert.match(block, /closed_at IS NULL AND closes_at IS NOT NULL AND closes_at < now\(\)/,
|
|
90
|
+
'open + due, on the DATABASE clock; NULL closes_at (no clock) never expires');
|
|
91
|
+
assert.match(block, /ORDER BY closes_at/, 'oldest deadline first');
|
|
92
|
+
const migration = fs.readFileSync(path.join(GOV, 'migrations', 'government_003_board_tables.sql'), 'utf8');
|
|
93
|
+
assert.ok(migration.includes('government_board_items_open_by_deadline'),
|
|
94
|
+
'the partial index government_003 built for this sweep exists');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('the manifest declares the poller, and start/stop are idempotent + unref-safe', () => {
|
|
98
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(GOV, 'module.json'), 'utf8'));
|
|
99
|
+
assert.ok(manifest.contributes.pollers.includes('board-expiry'),
|
|
100
|
+
'the loader starts it only when declared (startModulePollers contract)');
|
|
101
|
+
// Behavioural start/stop with the sweep stubbed inert: no timer may survive stop().
|
|
102
|
+
poller.start({ listFn: async () => [], log: () => {}, error: () => {} });
|
|
103
|
+
poller.start(); // second start is a no-op (already started)
|
|
104
|
+
poller.stop();
|
|
105
|
+
poller.stop(); // second stop is a no-op
|
|
106
|
+
// The timers are unref'd in start(); nothing to await — reaching here without
|
|
107
|
+
// the test runner hanging IS the assertion.
|
|
108
|
+
assert.ok(true);
|
|
109
|
+
});
|
|
@@ -38,6 +38,7 @@ const NON_GRANT_MIGRATIONS = new Set([
|
|
|
38
38
|
'government_002_rename_permission_keys.sql', // ADR 0174 key rename (task 1003006)
|
|
39
39
|
'government_003_board_tables.sql', // ADR 0175 Board Room schema (task 1003016)
|
|
40
40
|
'government_005_seeded_rank_descriptions.sql', // ADR 0174 seeded-description text fix (task 1003083)
|
|
41
|
+
'government_006_returned_items_index.sql', // R13 returned-item lookup index (task 1003026)
|
|
41
42
|
]);
|
|
42
43
|
|
|
43
44
|
// Permission keys RENAMED after the grant migrations that first granted them.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// tests/idea_resubmit.mjs — return-to-originator, and resubmission (BV1.R13 of
|
|
2
|
+
// goal 1000069, criterion C2: a rejected idea never dies and never stalls).
|
|
3
|
+
//
|
|
4
|
+
// DB-free the idea_full_capture way: pool.query patched on the doorway, the
|
|
5
|
+
// REAL route handler driven through a stub req/res, a stub `government`
|
|
6
|
+
// provider registered on the seam registry (hasProvider-guarded, so this
|
|
7
|
+
// process's stub wins and the real module never loads a DB).
|
|
8
|
+
//
|
|
9
|
+
// What must hold:
|
|
10
|
+
// 1. AUTHOR-ONLY — the rejection went to the originator; anyone else is 403.
|
|
11
|
+
// 2. THE FLAGGED SECTION IS CHECKED per-field: a resubmission that reworked
|
|
12
|
+
// everything EXCEPT what was objected to is told flagged_section_changed
|
|
13
|
+
// false; one that answered it is told true.
|
|
14
|
+
// 3. The board objection OUTRANKS the mechanical sub-bar flag; the flag is
|
|
15
|
+
// the fallback when the board never returned it.
|
|
16
|
+
// 4. A passing resubmission re-opens a window (the prior item is closed, so
|
|
17
|
+
// R07's partial unique index admits a new one); a still-sub-bar one comes
|
|
18
|
+
// back with FRESH feedback on the new weakest section.
|
|
19
|
+
// 5. A quick idea cannot resubmit (409) — this is the Full Idea loop.
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const api = require('../src/module-api.js');
|
|
25
|
+
|
|
26
|
+
// ---- the government port stub (registered BEFORE the routes load) ----------
|
|
27
|
+
let rejectionAnswer = null; // what latestRejectionForIdea returns
|
|
28
|
+
let windowAnswer = { opened: true, item: { id: '31', closes_at: null } };
|
|
29
|
+
const portCalls = [];
|
|
30
|
+
if (!api.hasProvider('government')) {
|
|
31
|
+
api.registerProvider('government', {
|
|
32
|
+
latestRejectionForIdea: async (id) => { portCalls.push(['rejection', id]); return rejectionAnswer; },
|
|
33
|
+
openBoardWindowForIdea: async (args) => { portCalls.push(['open', args]); return windowAnswer; },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- the pool stub ----------------------------------------------------------
|
|
38
|
+
let queries = [];
|
|
39
|
+
let storedIdea = null; // what GET-by-id returns
|
|
40
|
+
api.pool.query = async (sql, params = []) => {
|
|
41
|
+
queries.push({ sql, params });
|
|
42
|
+
if (/FROM idea_inbox i/i.test(sql) && /WHERE i\.id = \$1/i.test(sql)) {
|
|
43
|
+
return { rows: storedIdea ? [storedIdea] : [] };
|
|
44
|
+
}
|
|
45
|
+
if (/INSERT INTO ideas_grades/i.test(sql)) {
|
|
46
|
+
return { rows: [{ idea_id: params[0], grade: params[1], template_key: params[2], field_values: JSON.parse(params[3]), completeness_score: params[4] }] };
|
|
47
|
+
}
|
|
48
|
+
return { rows: [] };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
52
|
+
const routesFactory = require('../modules/ideas/routes/inbox.js');
|
|
53
|
+
const router = routesFactory({});
|
|
54
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/inbox/:id/resubmit' && l.route.methods.post);
|
|
55
|
+
assert.ok(layer, 'the resubmit route is declared');
|
|
56
|
+
const handlers = layer.route.stack.map((s) => s.handle);
|
|
57
|
+
const resubmit = handlers[handlers.length - 1];
|
|
58
|
+
|
|
59
|
+
function call(bodyIn, { builderId = '9', id = '77' } = {}) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const req = { body: bodyIn, params: { id }, builder: { id: builderId, rank: 'metic' }, query: {} };
|
|
62
|
+
const res = {
|
|
63
|
+
statusCode: 200,
|
|
64
|
+
status(c) { this.statusCode = c; return this; },
|
|
65
|
+
json(payload) { resolve({ status: this.statusCode, payload }); },
|
|
66
|
+
fail(code, status, extra) {
|
|
67
|
+
const s = typeof status === 'number' ? status : (status && status.status) || 500;
|
|
68
|
+
resolve({ status: s, payload: { error: code, ...(extra || {}) } });
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
resubmit(req, res);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const FULL = Object.fromEntries(
|
|
76
|
+
BASE_TEMPLATE.fields.map((f) => [f.key, `A substantive answer for ${f.key}, long enough to clear the floor.`]),
|
|
77
|
+
);
|
|
78
|
+
const fullIdea = (over = {}) => ({
|
|
79
|
+
id: '77', title: 'T', status: 'open', captured_by: '9',
|
|
80
|
+
grade: 'full', template_key: 'base-v1', field_values: { ...FULL, fit: 'thin' }, completeness_score: 55,
|
|
81
|
+
...over,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
let passed = 0, failed = 0;
|
|
85
|
+
async function test(name, fn) {
|
|
86
|
+
queries = []; portCalls.length = 0;
|
|
87
|
+
rejectionAnswer = null; windowAnswer = { opened: true, item: { id: '31', closes_at: null } };
|
|
88
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
89
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await test('author-only: anyone else is 403, and nothing is written', async () => {
|
|
93
|
+
storedIdea = fullIdea();
|
|
94
|
+
const r = await call({ field_values: FULL }, { builderId: '4' });
|
|
95
|
+
assert.equal(r.status, 403);
|
|
96
|
+
assert.equal(r.payload.error, 'not_your_idea');
|
|
97
|
+
assert.ok(!queries.some((q) => /INSERT INTO ideas_grades/i.test(q.sql)), 'no grade write');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await test('a quick idea cannot resubmit — the Full Idea loop only', async () => {
|
|
101
|
+
storedIdea = fullIdea({ grade: 'quick', template_key: null, field_values: null });
|
|
102
|
+
const r = await call({ field_values: FULL });
|
|
103
|
+
assert.equal(r.status, 409);
|
|
104
|
+
assert.equal(r.payload.error, 'not_a_full_idea');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await test('a board objection outranks the mechanical flag, and an ANSWERED section reads changed:true', async () => {
|
|
108
|
+
storedIdea = fullIdea();
|
|
109
|
+
rejectionAnswer = { source: 'board', item_id: '30', section_key: 'purpose', reason_md: 'purpose is vague', closed_at: 'T' };
|
|
110
|
+
const revised = { ...FULL, purpose: 'A completely reworked purpose that answers the objection head-on.' };
|
|
111
|
+
const r = await call({ field_values: revised });
|
|
112
|
+
assert.equal(r.status, 200);
|
|
113
|
+
assert.equal(r.payload.flagged_section, 'purpose', 'the human objection wins over the weakest-section flag');
|
|
114
|
+
assert.equal(r.payload.flagged_section_changed, true);
|
|
115
|
+
assert.equal(r.payload.rejection_source, 'board');
|
|
116
|
+
assert.deepEqual(r.payload.board_window, { item_id: '31', closes_at: null }, 'a new window opened');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await test('reworking everything EXCEPT the flagged section reads changed:false', async () => {
|
|
120
|
+
storedIdea = fullIdea();
|
|
121
|
+
rejectionAnswer = { source: 'board', item_id: '30', section_key: 'purpose', reason_md: 'r', closed_at: 'T' };
|
|
122
|
+
const revised = Object.fromEntries(Object.entries(FULL).map(([k, v]) => [k, k === 'purpose' ? v : `${v} — now even better.`]));
|
|
123
|
+
storedIdea = fullIdea({ field_values: FULL });
|
|
124
|
+
const r = await call({ field_values: revised });
|
|
125
|
+
assert.equal(r.payload.flagged_section_changed, false, 'told plainly: the objected-to section did not move');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('with no board return, the sub-bar weakest-section flag is the fallback', async () => {
|
|
129
|
+
storedIdea = fullIdea(); // old fit is 'thin' — the mechanical weakest
|
|
130
|
+
rejectionAnswer = null;
|
|
131
|
+
const revised = { ...FULL, fit: 'A fit section with real substance now, comfortably past the floor.' };
|
|
132
|
+
const r = await call({ field_values: revised });
|
|
133
|
+
assert.equal(r.payload.flagged_section, 'fit');
|
|
134
|
+
assert.equal(r.payload.rejection_source, 'grade');
|
|
135
|
+
assert.equal(r.payload.flagged_section_changed, true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await test('a still-sub-bar resubmission gets FRESH feedback and no window', async () => {
|
|
139
|
+
storedIdea = fullIdea();
|
|
140
|
+
windowAnswer = { opened: false, reason: 'below_bar' };
|
|
141
|
+
const stillThin = { ...FULL, big_picture: 'x' }; // new weakest is big_picture
|
|
142
|
+
const r = await call({ field_values: stillThin });
|
|
143
|
+
assert.equal(r.status, 200);
|
|
144
|
+
assert.equal(r.payload.board_window, undefined);
|
|
145
|
+
assert.equal(r.payload.idea.rejection.section_key, 'big_picture', 'the NEW weakest section, not the old flag');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('an invalid field set is a 400 naming the field, before any write', async () => {
|
|
149
|
+
storedIdea = fullIdea();
|
|
150
|
+
const r = await call({ field_values: { ...FULL, purpose: 42 } });
|
|
151
|
+
assert.equal(r.status, 400);
|
|
152
|
+
assert.equal(r.payload.error, 'invalid_field_values');
|
|
153
|
+
assert.ok(!queries.some((q) => /INSERT INTO ideas_grades/i.test(q.sql)));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await test('a missing idea is 404', async () => {
|
|
157
|
+
storedIdea = null;
|
|
158
|
+
const r = await call({ field_values: FULL });
|
|
159
|
+
assert.equal(r.status, 404);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
console.log(`\nidea_resubmit: ${passed} passed, ${failed} failed`);
|
|
163
|
+
if (failed > 0) process.exit(1);
|