@bongos/core 1.19.22 → 1.19.23

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 CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.22",
6
- "core_contract": "1.19.22",
7
- "source_commit": "e089ef90ffdf1b4da4f4a6b7f70d0fb10426bf42",
5
+ "core_version": "1.19.23",
6
+ "core_contract": "1.19.23",
7
+ "source_commit": "fcdc2c0100a2b96183b34980f7c33572b67c7d1f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-11T01:24:29.274Z",
9
+ "built_at": "2026-08-11T01:44:34.493Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 285,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1510,
20
- "tree_sha256": "0a775991e628ae986369cd819f6b384887fa20c1095f7e7263f07f13ad0e8167",
20
+ "tree_sha256": "80499ca716f5acc869706f3832aba22ffa0facdf31abd6eca71baec9b273d628",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -352,7 +352,7 @@
352
352
  {
353
353
  "path": "clients/bongos-client/index.d.ts",
354
354
  "mode": "0000644",
355
- "sha256": "2e321ea71ad8b24a5562b97dc76bedae62e3a313fd8cbb72f415fa575bd686a1"
355
+ "sha256": "7e6732aedaf7aaa3c657fc05e2dfd1dcc63e0fb06586d4077248d79d6198297e"
356
356
  },
357
357
  {
358
358
  "path": "clients/bongos-client/index.mjs",
@@ -1277,12 +1277,12 @@
1277
1277
  {
1278
1278
  "path": "docs/api-reference.md",
1279
1279
  "mode": "0000644",
1280
- "sha256": "c2e909c0f62a643dc2ff4c705cc6ffccf279dcc588945d1b39147d09d70c5f5b"
1280
+ "sha256": "e28575936d1cb0cce684b20723743fd0d801ccd0a7ec58fff5360e160b23b5e7"
1281
1281
  },
1282
1282
  {
1283
1283
  "path": "docs/api/openapi.json",
1284
1284
  "mode": "0000644",
1285
- "sha256": "43b270dbfaa283c2c793d8f779b497cfd0c6da4f4a4cef07f4516d1c4e582946"
1285
+ "sha256": "b232ca69ae97842be312b76f6ac49ca66090c5c8fef392f609bb857e5f5c2979"
1286
1286
  },
1287
1287
  {
1288
1288
  "path": "docs/architecture.md",
@@ -2882,7 +2882,7 @@
2882
2882
  {
2883
2883
  "path": "modules/grading/grader.js",
2884
2884
  "mode": "0000644",
2885
- "sha256": "b421b3749396e14bbfa203519a0d25083f540c222deccfc755ad6ca808239859"
2885
+ "sha256": "a08e9c017a07bffa311a9de6df007c30d9f3766c86d0ce2ec88660fed60d37aa"
2886
2886
  },
2887
2887
  {
2888
2888
  "path": "modules/grading/module.json",
@@ -3437,7 +3437,7 @@
3437
3437
  {
3438
3438
  "path": "modules/lifecycle/routes/done-when.js",
3439
3439
  "mode": "0000644",
3440
- "sha256": "f77651b5418d6eb99e78ecf4c864f239ba75a149444a88eae59a5c3082f48cae"
3440
+ "sha256": "cec4dce0ab8d11b16de73dbee8adb782f47c12a32d203dd1ff726afd23caadf3"
3441
3441
  },
3442
3442
  {
3443
3443
  "path": "modules/lifecycle/routes/gate-approvals.js",
@@ -3457,7 +3457,7 @@
3457
3457
  {
3458
3458
  "path": "modules/lifecycle/routes/tasks.js",
3459
3459
  "mode": "0000644",
3460
- "sha256": "14320bb5a3257a422854c41362f010042ed390bdd7efcf1b4959ed72f0ee1f9f"
3460
+ "sha256": "add6acd00adc842e90348ca789da0a473aa2dc68d949cf2b5586c68ef5eaa1be"
3461
3461
  },
3462
3462
  {
3463
3463
  "path": "modules/lifecycle/routes/versions.js",
@@ -4002,12 +4002,12 @@
4002
4002
  {
4003
4003
  "path": "package-lock.json",
4004
4004
  "mode": "0000644",
4005
- "sha256": "f85ec26a3a5cbefec1aca8407043cb2eb2886120722a79126d364736f1a67584"
4005
+ "sha256": "969211290cee8ccee357109c325ff4741c8cce3319d520b22506126099beea7e"
4006
4006
  },
4007
4007
  {
4008
4008
  "path": "package.json",
4009
4009
  "mode": "0000644",
4010
- "sha256": "9275da3329d188788a0e69b719231cd23ae38b3499802edbcefebded17317067"
4010
+ "sha256": "b00d5423d5019e305bb488af793202ccb4d3089da6c129851e32f85ea4219904"
4011
4011
  },
4012
4012
  {
4013
4013
  "path": "public-docs/index.html",
@@ -5197,7 +5197,7 @@
5197
5197
  {
5198
5198
  "path": "scripts/gds/ship.js",
5199
5199
  "mode": "0000644",
5200
- "sha256": "65af2a0d6fc54ee3dbb3c7faa44796fed2a5817fa48073cebfd9d6a15837fec7"
5200
+ "sha256": "085a7feeff06fa7d2b833c483795161e2c4a6ee79630e2f09d7ae2f06eeb253c"
5201
5201
  },
5202
5202
  {
5203
5203
  "path": "scripts/gds/smoke-batch-claim.sh",
@@ -5627,7 +5627,7 @@
5627
5627
  {
5628
5628
  "path": "src/module-api.js",
5629
5629
  "mode": "0000644",
5630
- "sha256": "937021d749587baee02de0c83fa721b99a0e23850c5c12aa5332424673e5d6af"
5630
+ "sha256": "0e4203ec0098beb9a54a4ea56b3c5701cfca5bb02b6676114a9257f5b353aff5"
5631
5631
  },
5632
5632
  {
5633
5633
  "path": "src/module-loader/loader.js",
@@ -6137,7 +6137,7 @@
6137
6137
  {
6138
6138
  "path": "tests/criterion_authoring_gate.mjs",
6139
6139
  "mode": "0000644",
6140
- "sha256": "23d8c31f310e7ffb840dce3850c1eb2eab59b5513489bde19edb22c5bab6eb75"
6140
+ "sha256": "c3cafa042f7ac9715569b9c3312e27b920e88497047415cb653bb576cf5380a9"
6141
6141
  },
6142
6142
  {
6143
6143
  "path": "tests/criterion_progress.mjs",
@@ -6582,12 +6582,12 @@
6582
6582
  {
6583
6583
  "path": "tests/grade_server_authoritative.mjs",
6584
6584
  "mode": "0000644",
6585
- "sha256": "c582e967a183ac9fcff99c2059543ac34f2dd98b39f21b5053d2c463287f01e6"
6585
+ "sha256": "c650bd3c79d469f3ffb1cdada6f23521dd09c97844affd5b9e7f340d1796daa6"
6586
6586
  },
6587
6587
  {
6588
6588
  "path": "tests/grade_unavailable_display.mjs",
6589
6589
  "mode": "0000644",
6590
- "sha256": "c6082286a017edc4369e4766b346d8ea981667a4270839868d7b846cde154782"
6590
+ "sha256": "82746689da273a8bd9d53d80204b15147d87574d2331c3a70d736d95a6126a09"
6591
6591
  },
6592
6592
  {
6593
6593
  "path": "tests/grader.mjs",
@@ -6992,7 +6992,7 @@
6992
6992
  {
6993
6993
  "path": "tests/override_request_ownership.mjs",
6994
6994
  "mode": "0000644",
6995
- "sha256": "da77422fac6c4624315b8c51bd8974a93a5f64f0c72c1ef1a3665744e42dc15c"
6995
+ "sha256": "dac9d9891eac706432aff97ce35e27692064a2791dc1e546215c74e68b81e8a8"
6996
6996
  },
6997
6997
  {
6998
6998
  "path": "tests/package_core.mjs",
@@ -7502,7 +7502,7 @@
7502
7502
  {
7503
7503
  "path": "tests/task_ownership_gate.mjs",
7504
7504
  "mode": "0000644",
7505
- "sha256": "0a0aa9bd7147256422f348567a67e206bafeefe88430195834ddebf405b59bdd"
7505
+ "sha256": "cfb57e35ce65f991309e4de64c03f9252e19117a1ad406cf9bb04c1716f57746"
7506
7506
  },
7507
7507
  {
7508
7508
  "path": "tests/touches_overlap.mjs",
@@ -131,7 +131,7 @@ export interface PatchMeSkillPrefsResponse { prefs: unknown; effective: unknown;
131
131
  export interface PatchMeSoundPrefsResponse { prefs: unknown; effective: unknown; effective_map: unknown; volume: unknown; defaults: unknown; known_sounds: unknown }
132
132
  export interface PatchProjectsIdFeaturedRequest { featured: boolean }
133
133
  export interface PatchProjectsIdFeaturedResponse { project: unknown }
134
- export interface PatchTasksIdRequest { kind?: string; discipline?: string; requires_rank?: string; module_key?: string; goal_id?: number; parent_task_id?: StringifiableId; parallel_safe?: unknown; newcomer_friendly?: boolean; xenos_claimable?: boolean; security_sensitive?: boolean; automation_tag?: string }
134
+ export interface PatchTasksIdRequest { kind?: string; discipline?: string; requires_rank?: string; module_key?: string; goal_id?: number; parent_task_id?: StringifiableId; parallel_safe?: unknown; newcomer_friendly?: boolean; xenos_claimable?: boolean; security_sensitive?: boolean; automation_tag?: string; status?: unknown }
135
135
  export interface PostAccessRequestsRequest { github_login: string; display_name?: string; note?: string }
136
136
  export interface PostAccessRequestsResponse { ok: boolean; request?: unknown; already_pending?: boolean }
137
137
  export interface PostAuthAppPairApproveRequest { pair_code: string }
@@ -12008,7 +12008,7 @@
12008
12008
  "versions"
12009
12009
  ],
12010
12010
  "summary": "POST /versions/:id/done-when",
12011
- "description": "Auth (Metic+, then the goal's own authority): create a done-when criterion at RUNTIME. ADR 0105 moved scope-truth seeds (versions/goals/criteria) out of the normal migration path, so a brand-new criterion on a RUNNING instance can no longer be added by a plain migration (migrations/instance/ applies only under the owner opt-in GDS_APPLY_INSTANCE_SEEDS=1, a DR step). This is the vector a planning session uses to add a criterion to a live version WITHOUT a droplet migrate. ADR 0154 — authority over a GOAL's criteria follows GOAL OWNERSHIP, not global rank. A Metic who may create the goal (POST /goals) and author the tasks under it (POST /tasks, ADR 0090) may now write the criteria those tasks gate — but only inside a goal they own or manage. Deliberately NOT a flat re-rank: - An unsatisfied criterion permanently blocks version close (version-close.js counts satisfied === total per VERSION, regardless of goal) and goal auto-achieve (db.achieveGoalIfComplete). There is NO delete route, so it cannot be undone via the API — the same backward ratchet as unsatisfy, which stays Archon. - So a non-Archon must name a goal_id (version-level criteria stay Archon), must be that goal's OWNER or MANAGER (isGoalOwnerOrManager — the same predicate behind visibility/invite/role management), and the goal must be open on a planning/building version. That bounds the blast radius to a workspace they already hold. An unauthorized caller is masked behind bad_goal_id so the route is not an existence oracle (ADR 0112). Pinned in route-rank-check.MODULE_EXPECTED_RANKS. rank: metic+archon — criterion create, walled by goal ownership (ADR 0154).\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `criterion.create` (all required).",
12011
+ "description": "Auth (Metic+, then the goal's own authority): create a done-when criterion at RUNTIME. ADR 0105 moved scope-truth seeds (versions/goals/criteria) out of the normal migration path, so a brand-new criterion on a RUNNING instance can no longer be added by a plain migration (migrations/instance/ applies only under the owner opt-in GDS_APPLY_INSTANCE_SEEDS=1, a DR step). This is the vector a planning session uses to add a criterion to a live version WITHOUT a droplet migrate. ADR 0154 — authority over a GOAL's criteria follows GOAL OWNERSHIP, not global rank. A Metic who may create the goal (POST /goals) and author the tasks under it (POST /tasks, ADR 0090) may now write the criteria those tasks gate — but only inside a goal they own or manage. Deliberately NOT a flat re-rank: - An unsatisfied criterion permanently blocks version close (version-close.js counts satisfied === total per VERSION, regardless of goal) and goal auto-achieve (db.achieveGoalIfComplete). There is NO delete route, so it cannot be undone via the API — the same backward ratchet as unsatisfy, which stays Archon. - So a non-Archon must name a goal_id (version-level criteria stay Archon), must be that goal's OWNER or MANAGER (isGoalOwnerOrManager — the same predicate behind visibility/invite/role management), and the goal must be open on a planning/building version. That bounds the blast radius to a workspace they already hold. An unauthorized caller gets an honest 403 goal_not_yours (task 1002614 — goal reads are open to any builder, so there is no existence oracle for a mask to protect). Pinned in route-rank-check.MODULE_EXPECTED_RANKS. rank: metic+archon — criterion create, walled by goal ownership (ADR 0154).\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `criterion.create` (all required).",
12012
12012
  "x-rank": "metic+archon",
12013
12013
  "x-source": "modules/lifecycle/routes/done-when.js",
12014
12014
  "x-permissions": [
@@ -13788,7 +13788,8 @@
13788
13788
  "automation_tag": {
13789
13789
  "type": "string",
13790
13790
  "maxLength": 64
13791
- }
13791
+ },
13792
+ "status": {}
13792
13793
  },
13793
13794
  "additionalProperties": false
13794
13795
  },
@@ -463,7 +463,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
463
463
  | GET | `/api/bongos/tasks` | `any-builder` | — | reads (filtered task list); preserved per CRITICAL §4. |
464
464
  | POST | `/api/bongos/tasks` | `metic+archon` | `version_id`, `title`, `description`, `status`, `touches`, `est_minutes`, `est_cost_usd`, `manual_degree`, `priority`, `automation_tag`, `credits_reward`, `source`, `source_ref`, `value_summary`, `kind`, `discipline`, `parallel_safe`, `newcomer_friendly`, `security_sensitive`, `requires_rank`, `auto_detect_touches`, `criterion_ids`, `module_key`, `goal_id` | Task create/promote/edit (plan/scope mutation) is Metic+ as of ADR 0090 (owner decision 2026-06-26): a Metic who can run idea-triage may … |
465
465
  | GET | `/api/bongos/tasks/:id` | `any-builder` | — | single-task read; preserved per CRITICAL §4 (supervisor hot path). |
466
- | PATCH | `/api/bongos/tasks/:id` | `metic+archon` | `kind`, `discipline`, `requires_rank`, `module_key`, `goal_id`, `parent_task_id`, `parallel_safe`, `newcomer_friendly`, `xenos_claimable`, `security_sensitive`, `automation_tag` | PATCH /tasks/:id — supports body.parent_task_id (V2 C2), body.kind (V2 B1), body.discipline (mig 014), body.parallel_safe (mig 028), and … |
466
+ | PATCH | `/api/bongos/tasks/:id` | `metic+archon` | `kind`, `discipline`, `requires_rank`, `module_key`, `goal_id`, `parent_task_id`, `parallel_safe`, `newcomer_friendly`, `xenos_claimable`, `security_sensitive`, `automation_tag`, `status` | PATCH /tasks/:id — supports body.parent_task_id (V2 C2), body.kind (V2 B1), body.discipline (mig 014), body.parallel_safe (mig 028), and … |
467
467
  | POST | `/api/bongos/tasks/:id/abandon` | `metic+archon` | `reason` | POST /tasks/:id/abandon — terminal "retire this task" transition (task 987). |
468
468
  | POST | `/api/bongos/tasks/:id/attest-gate` | `any-builder` | `head_sha` | POST /tasks/:id/attest-gate — body: { head_sha }. |
469
469
  | POST | `/api/bongos/tasks/:id/confirm` | `any-builder` | `verified` | |
@@ -734,6 +734,12 @@ function serverSideGuards(gradeResult, { committedFiles, builderRank, diffStats,
734
734
  downgraded_to: downgradeTo,
735
735
  client_passed: clientPassed,
736
736
  tripped: guardsTripped,
737
+ // task 1002614 (map P-2): a permission_path trip is an AUTHORIZATION
738
+ // refusal of the builder's rank over protected paths — not a verdict on
739
+ // the code. Named here so clients render it as a wall, not a quality
740
+ // FAIL the builder should fix-and-retry (the envelope used to teach
741
+ // exactly that wrong lesson). route_rank stays a code finding.
742
+ authorization_refusal: guardsTripped.some((g) => g.guard === 'permission_path'),
737
743
  },
738
744
  },
739
745
  notes_md: `${base.notes_md || ''}${guardNote}`,
@@ -26,11 +26,11 @@ const { validateOrRespond, parseId, asyncHandler, LIMITS } = api;
26
26
  // pinned by a DB-free unit test (the authorizeGoalTaskCreate precedent in
27
27
  // routes/goals.js). The route resolves goal/version first — their absence is a
28
28
  // 404/400 there — so this gate only decides AUTHORITY, in this order:
29
- // archon → allow · goal named? · owner|manager? (MASKED) · goal open? · version open?
30
- // Authority sits ahead of both state checks on purpose — see the no-leak note below.
31
- // An Archon short-circuits: version-level (goal-less) criteria and any goal in any
32
- // state stay theirs. Returns { ok:true }, { ok:false, status, body }, or
33
- // { ok:false, maskAsBadGoalId:true } (the route renders the identical 400).
29
+ // archon → allow · goal named? · owner|manager? (403) · goal open? · version open?
30
+ // Authority sits ahead of both state checks on purpose — an outsider never learns
31
+ // goal state from the 409s below. An Archon short-circuits: version-level
32
+ // (goal-less) criteria and any goal in any state stay theirs. Returns
33
+ // { ok:true } or { ok:false, status, body }.
34
34
  //
35
35
  // "Version open" = `planning` or `building`. NOT just `building`: a planning session
36
36
  // scopes a version at status='planning', which is the main moment criteria get
@@ -47,16 +47,18 @@ function authorizeCriterionCreate({ goal, version, actorId, actorRank, actorMemb
47
47
  message: 'Name the goal this criterion belongs to — a version-level criterion is Archon-only (ADR 0154).',
48
48
  } };
49
49
  }
50
- // NO-LEAK ORDERING — authority BEFORE state, and the refusal is MASKED.
51
- // Before ADR 0154 this route was Archon-only, and an Archon can already read
52
- // every goal, so no probe existed. Now a Metic may call it, and distinguishable
53
- // refusals (400 "no such goal" vs 403 "not yours" vs 409 "not open") would turn
54
- // it into an oracle for which goal ids exist on a version — including PRIVATE
55
- // goals (ADR 0112). So a caller who is not the owner/manager gets byte-identical
56
- // `bad_goal_id` to a caller naming a goal that does not exist. Only someone who
57
- // already holds the goal learns anything further about it.
50
+ // Authority BEFORE state (an outsider still never sees goal state via the 409
51
+ // below). Until task 1002614 the refusal here was MASKED behind the same
52
+ // `bad_goal_id` 400 as an absent goal, as an anti-oracle for goal ids (ADR
53
+ // 0112). The mask asserted a falsehood about a goal that exists and bought no
54
+ // secrecy: ADR 0112 privacy is join-gating, not read-hiding — GET /goals/:id
55
+ // serves any authenticated builder the goal, its members, criteria and state
56
+ // (choke-point map D-3). So the refusal now names its wall.
58
57
  if (!isGoalOwnerOrManager({ goal, actorId, actorRank, actorMembership })) {
59
- return { ok: false, maskAsBadGoalId: true };
58
+ return { ok: false, status: 403, body: {
59
+ error: 'goal_not_yours',
60
+ message: `Criteria on goal ${goal.id} can be authored only by the goal's owner or a manager (ADR 0154). Ask them to add it — or to grant you the manager role.`,
61
+ } };
60
62
  }
61
63
  if (goal.status !== 'open') {
62
64
  return { ok: false, status: 409, body: {
@@ -112,8 +114,9 @@ module.exports = function buildDoneWhenRouter() {
112
114
  // must be that goal's OWNER or MANAGER (isGoalOwnerOrManager — the same
113
115
  // predicate behind visibility/invite/role management), and the goal must be
114
116
  // open on a planning/building version. That bounds the blast radius to a
115
- // workspace they already hold. An unauthorized caller is masked behind
116
- // bad_goal_id so the route is not an existence oracle (ADR 0112).
117
+ // workspace they already hold. An unauthorized caller gets an honest 403
118
+ // goal_not_yours (task 1002614 — goal reads are open to any builder, so
119
+ // there is no existence oracle for a mask to protect).
117
120
  // Pinned in route-rank-check.MODULE_EXPECTED_RANKS.
118
121
  // rank: metic+archon — criterion create, walled by goal ownership (ADR 0154).
119
122
  router.post('/versions/:id/done-when', auth.requireBuilder, auth.requirePermission('criterion.create'), asyncHandler('POST /versions/:id/done-when', async (req, res) => {
@@ -127,9 +130,9 @@ module.exports = function buildDoneWhenRouter() {
127
130
  })) return;
128
131
  const version = await db.getVersion(versionId);
129
132
  if (!version) return res.fail('version_not_found', { status: 404, message: `No version '${versionId}'.` });
130
- // The ONE bad_goal_id refusal, used both for a genuinely absent/foreign goal
131
- // and for the masked unauthorized case below — same code, same message, same
132
- // envelope, so the two are indistinguishable on the wire.
133
+ // bad_goal_id now means exactly what it says: the goal is absent or belongs
134
+ // to another version. (The unauthorized case rendered through this same
135
+ // helper until task 1002614 — see the mask note in authorizeCriterionCreate.)
133
136
  const badGoalId = () => res.fail('bad_goal_id', { status: 400, message: `goal_id ${req.body.goal_id} does not exist on version '${versionId}'.` });
134
137
  // A goal_id, if given, must exist AND belong to this version.
135
138
  let goal = null;
@@ -148,7 +151,6 @@ module.exports = function buildDoneWhenRouter() {
148
151
  goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
149
152
  });
150
153
  if (!decision.ok) {
151
- if (decision.maskAsBadGoalId) return badGoalId();
152
154
  return res.status(decision.status).json(decision.body);
153
155
  }
154
156
  try {
@@ -129,6 +129,15 @@ async function gateTaskOwnership(req, res, taskId, { allowSupervisor = false } =
129
129
  if (allowSupervisor && rank === 'metic') return true;
130
130
  const holder = await db.getLastClaimHolder(taskId);
131
131
  if (holder && String(holder.builder_id) === String(req.builder.id)) return true;
132
+ // task 1002614 (map N-2): a mistyped id used to earn the same not_your_task
133
+ // 403 as someone else's work — a misattribution that sent builders debugging
134
+ // ownership. An ABSENT task is the clean 404 an Archon already saw (they pass
135
+ // above and hit the handler's own lookup). A never-claimed task that EXISTS
136
+ // still fails closed with the 403 below (the L2 credit-theft block).
137
+ if (!holder && !(await db.getTask(taskId))) {
138
+ res.fail('task_not_found', 404);
139
+ return false;
140
+ }
132
141
  res.fail('not_your_task', { status: 403, message: 'Only the task\'s claim holder (or an Archon) may grade/confirm/ship it.' });
133
142
  return false;
134
143
  }
@@ -764,6 +773,11 @@ module.exports = function buildTasksRouter() {
764
773
  xenos_claimable: { type: 'boolean' }, // pre-rename alias for newcomer_friendly
765
774
  security_sensitive: { type: 'boolean' }, // task 990 — Archon-only ship broadcast
766
775
  automation_tag: { type: 'string', maxLength: 64 },
776
+ // task 1002614 (map D-18): DECLARED so strict-mode admits the request far
777
+ // enough to reach the deliberate status_not_patchable hint below (#514) —
778
+ // the hint had been unreachable dead code behind unknown_field since the
779
+ // R11 strict-validation sweep landed after it.
780
+ status: {},
767
781
  })) return;
768
782
  const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
769
783
  if (id === null) return;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.22",
3
+ "version": "1.19.23",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.22",
9
+ "version": "1.19.23",
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.22",
3
+ "version": "1.19.23",
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",
@@ -2776,6 +2776,36 @@ function formatGradeOutcome(g, taskId) {
2776
2776
  return out.join('\n');
2777
2777
  }
2778
2778
 
2779
+ // task 1002614 (map P-2): a protected-path refusal used to render as an
2780
+ // ordinary quality FAIL — "fix the issues and --regrade" — although no rank of
2781
+ // the builder's can land that diff. Present it as the authorization wall it is
2782
+ // (the #943 grader-unavailable branch is the precedent for a distinct shape).
2783
+ // Keyed off the guard name so it also reads grades from servers predating the
2784
+ // authorization_refusal marker.
2785
+ const guardTripped = g.signals?.server_guard?.tripped || [];
2786
+ const permRefusal = g.signals?.server_guard?.authorization_refusal === true
2787
+ || guardTripped.some((t) => t.guard === 'permission_path');
2788
+ if (!g.passed && permRefusal) {
2789
+ out.push('');
2790
+ out.push('Subagent grade: REFUSED (authorization) · not a quality verdict — your work was not judged and rejected');
2791
+ out.push('');
2792
+ out.push('The committed diff touches PROTECTED surfaces above your rank, so the server');
2793
+ out.push('refused to accept it — no re-attempt at your rank can land this diff as-is.');
2794
+ if ((g.issues || []).length > 0) {
2795
+ out.push('');
2796
+ out.push('What was refused (file · rule):');
2797
+ for (const it of g.issues.filter((i) => String(i.worker || '').includes('permission-path'))) {
2798
+ out.push(` ${it.file || '?'} — ${it.summary || ''}`);
2799
+ }
2800
+ }
2801
+ out.push('');
2802
+ out.push('Ways forward:');
2803
+ out.push(' - Split the protected files out of your diff and re-ship the rest, or');
2804
+ out.push(' - hand this task to a builder at/above the floor the refusal names.');
2805
+ out.push(`(--regrade re-runs the same server check and will refuse again unless the diff changes.)`);
2806
+ return out.join('\n');
2807
+ }
2808
+
2779
2809
  const verdict = g.passed ? 'PASS' : 'FAIL';
2780
2810
  out.push('');
2781
2811
  out.push(`Subagent grade: ${verdict} · blended ${g.score}/10 · threshold ${g.threshold} (both axes)`);
package/src/module-api.js CHANGED
@@ -471,8 +471,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
471
471
  // landed since 1.19.20 with no explicit bump. run 31446766597. (task 1002620)
472
472
  // 1.19.22 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
473
473
  // landed since 1.19.21 with no explicit bump. run 31449168428. (task 1002620)
474
+ // 1.19.23 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
475
+ // landed since 1.19.22 with no explicit bump. run 31450197816. (task 1002620)
474
476
  // ---------------------------------------------------------------------------
475
- const CORE_VERSION = '1.19.22'; // CI auto-patch carrier (ADR 0161); see changelog above
477
+ const CORE_VERSION = '1.19.23'; // CI auto-patch carrier (ADR 0161); see changelog above
476
478
 
477
479
  // A namespaced logger so a module's log lines are attributable + consistent.
478
480
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -84,13 +84,15 @@ await test('a non-Archon with NO goal_id is refused — version-level criteria s
84
84
  await test('a plain MEMBER may not author — membership is not management', () => {
85
85
  const d = ask({ goal: G_OPEN, actorId: MEMBER, actorMembership: PLAIN });
86
86
  assert.equal(d.ok, false);
87
- assert.equal(d.maskAsBadGoalId, true);
87
+ assert.equal(d.status, 403);
88
+ assert.equal(d.body.error, 'goal_not_yours');
88
89
  });
89
90
 
90
91
  await test('a STRANGER (no membership at all) may not author', () => {
91
92
  const d = ask({ goal: G_OPEN, actorId: STRANGER, actorMembership: null });
92
93
  assert.equal(d.ok, false);
93
- assert.equal(d.maskAsBadGoalId, true);
94
+ assert.equal(d.status, 403);
95
+ assert.equal(d.body.error, 'goal_not_yours');
94
96
  });
95
97
 
96
98
  await test('an ACHIEVED goal is closed to new criteria (409) — no reopening settled scope', () => {
@@ -107,38 +109,43 @@ await test('an ARCHIVED goal is closed to new criteria (409)', () => {
107
109
  assert.equal(d.body.error, 'goal_not_open');
108
110
  });
109
111
 
110
- // ---- NO-LEAK: the route must not become a goal-existence oracle (ADR 0112) ----
111
- // Before ADR 0154 this route was Archon-only and Archons read every goal, so no
112
- // probe existed. Now a Metic may call it — so an unauthorized caller must not be
113
- // able to tell "this goal id exists but isn't mine" from "no such goal".
112
+ // ---- the refusal is HONEST (task 1002614, map D-3) ----
113
+ // Until task 1002614 an unauthorized caller was MASKED behind the same
114
+ // bad_goal_id 400 as an absent goal, as an anti-oracle for goal ids (ADR 0112).
115
+ // The mask asserted a falsehood about a goal that exists and protected nothing:
116
+ // ADR 0112 privacy is join-gating, not read-hiding — GET /goals/:id serves any
117
+ // authenticated builder the goal, members, criteria and state. So the refusal
118
+ // now names its wall, and these pins hold it honest.
114
119
 
115
- await test('an unauthorized caller is MASKED — no status/body of its own to compare', () => {
120
+ await test('an unauthorized caller gets an honest 403 naming the wall — not a false bad_goal_id', () => {
116
121
  const d = ask({ goal: G_OPEN, actorId: STRANGER, actorMembership: null });
117
- assert.equal(d.maskAsBadGoalId, true);
118
- assert.equal(d.status, undefined, 'the gate must not pick a distinguishable status');
119
- assert.equal(d.body, undefined, 'the route renders the identical bad_goal_id 400');
122
+ assert.equal(d.status, 403);
123
+ assert.equal(d.body.error, 'goal_not_yours');
124
+ assert.match(d.body.message, /owner or a manager/, 'the refusal names who CAN author');
125
+ assert.match(d.body.message, /ADR 0154/, 'and where the rule comes from');
120
126
  });
121
127
 
122
- await test('the mask is IDENTICAL across every unauthorized shape — open, achieved, archived, private-ish', () => {
128
+ await test('the refusal is uniform across goal states — authority still precedes state', () => {
129
+ // A stranger probing open/achieved/archived/system goals sees the SAME 403 —
130
+ // goal state stays behind the authority wall on this route (it is readable via
131
+ // GET /goals/:id, but this write route does not volunteer it).
123
132
  const probes = [G_OPEN, G_ACHIEVED, G_ARCHIVED, G_SYSTEM].map((goal) =>
124
133
  ask({ goal, actorId: STRANGER, actorMembership: null }));
125
- for (const d of probes) assert.deepEqual(d, { ok: false, maskAsBadGoalId: true });
126
- // A goal's STATE must not leak either: an outsider cannot learn that a goal id
127
- // exists-but-is-archived, which `goal_not_open` would have told them.
128
- assert.equal(new Set(probes.map((d) => JSON.stringify(d))).size, 1);
134
+ for (const d of probes) {
135
+ assert.equal(d.status, 403);
136
+ assert.equal(d.body.error, 'goal_not_yours');
137
+ }
129
138
  });
130
139
 
131
- await test('authority is checked BEFORE goal state — the ordering that makes the mask total', () => {
132
- // An owner on an archived goal DOES get goal_not_open (useful, and they already
133
- // know the goal exists). A stranger on the same goal gets the mask instead.
140
+ await test('authority is checked BEFORE goal state — an owner sees goal_not_open, a stranger never does', () => {
134
141
  assert.equal(ask({ goal: G_ARCHIVED, actorId: OWNER, actorMembership: LEAD }).body.error, 'goal_not_open');
135
- assert.equal(ask({ goal: G_ARCHIVED, actorId: STRANGER, actorMembership: null }).maskAsBadGoalId, true);
142
+ assert.equal(ask({ goal: G_ARCHIVED, actorId: STRANGER, actorMembership: null }).body.error, 'goal_not_yours');
136
143
  });
137
144
 
138
- await test('the route renders the mask through the SAME bad_goal_id helper as a truly absent goal', () => {
139
- assert.match(doneWhenSrc, /const badGoalId = \(\) => res\.fail\('bad_goal_id'/, 'one helper builds the refusal');
145
+ await test('bad_goal_id now means exactly what it says — the mask branch is gone from the route', () => {
146
+ assert.match(doneWhenSrc, /const badGoalId = \(\) => res\.fail\('bad_goal_id'/, 'one helper builds the absent/foreign refusal');
140
147
  assert.match(doneWhenSrc, /if \(!goal \|\| String\(goal\.version_id\) !== String\(versionId\)\) return badGoalId\(\);/, 'absent/foreign goal path');
141
- assert.match(doneWhenSrc, /if \(decision\.maskAsBadGoalId\) return badGoalId\(\);/, 'masked path — same helper, so same code+message+envelope');
148
+ assert.doesNotMatch(doneWhenSrc, /maskAsBadGoalId/, 'no code path renders an authorization refusal as bad_goal_id');
142
149
  });
143
150
 
144
151
  await test('a SHIPPED version is refused (409) — closed scope', () => {
@@ -171,7 +178,7 @@ await test('a PLANNING version is ALLOWED — that is when criteria get written'
171
178
  await test('a system goal (created_by NULL) has no owner — a Metic cannot claim it', () => {
172
179
  const d = ask({ goal: G_SYSTEM, actorId: OWNER, actorMembership: null });
173
180
  assert.equal(d.ok, false);
174
- assert.equal(d.maskAsBadGoalId, true);
181
+ assert.equal(d.body.error, 'goal_not_yours');
175
182
  // ...but a manager row still admits, and an Archon always does (below).
176
183
  assert.equal(ask({ goal: G_SYSTEM, actorId: OWNER, actorMembership: LEAD }).ok, true);
177
184
  });
@@ -215,7 +222,7 @@ await test('ordering: goal_id_required precedes the version check', () => {
215
222
 
216
223
  await test('ordering: an unauthorized caller never reaches the goal-state branch', () => {
217
224
  const d = ask({ goal: G_ARCHIVED, actorId: STRANGER, actorMembership: null });
218
- assert.equal(d.maskAsBadGoalId, true, 'goal_not_open would confirm the goal exists');
225
+ assert.equal(d.body.error, 'goal_not_yours', 'the refusal is the authority wall, never goal_not_open');
219
226
  });
220
227
 
221
228
  // ---- the wall is the SAME one the sibling goal routes use ----
@@ -106,6 +106,11 @@ await test('sub-Metic forged PASS touching protected path → hard FAIL', () =>
106
106
  assert.equal(g.signals.confidence, 0, 'fail forwards confidence 0 so the server gate also fails');
107
107
  assert.ok(g.signals.server_guard.tripped.some((t) => t.guard === 'permission_path'));
108
108
  assert.ok(g.issues.some((i) => i.worker === 'server-permission-path'));
109
+ // task 1002614 (map P-2): this FAIL is an AUTHORIZATION refusal, and the
110
+ // envelope must say so — a quality-verdict shape taught builders to
111
+ // fix-and-retry a diff no rank of theirs can land.
112
+ assert.equal(g.signals.server_guard.authorization_refusal, true,
113
+ 'a permission_path trip is named an authorization refusal, not a quality verdict');
109
114
  });
110
115
 
111
116
  await test('xenos forged PASS touching migrations/ → hard FAIL', () => {
@@ -129,6 +134,8 @@ await test('route-rank hard-fail (forged routes dir) flips a client PASS to FAIL
129
134
  assert.equal(g.passed, false, 'an ungated write route in the audited routes hard-fails the grade');
130
135
  assert.equal(g.signals.server_guard.downgraded_to, 'fail');
131
136
  assert.ok(g.signals.server_guard.tripped.some((t) => t.guard === 'route_rank'));
137
+ assert.equal(g.signals.server_guard.authorization_refusal, false,
138
+ 'route_rank is a CODE finding — only permission_path is an authorization refusal (task 1002614)');
132
139
  } finally {
133
140
  fs.rmSync(dir, { recursive: true, force: true });
134
141
  }
@@ -50,6 +50,34 @@ test('a real FAIL still prints the FAIL block + fix-the-issues guidance (#943 re
50
50
  assert.doesNotMatch(out, /GRADER UNAVAILABLE/);
51
51
  });
52
52
 
53
+ test('an authorization refusal (permission_path guard) → REFUSED, not a quality FAIL (task 1002614 / P-2)', () => {
54
+ // The server guard refused a sub-Metic diff over protected paths. The old
55
+ // rendering was a plain FAIL + "fix the issues" — teaching a re-attempt no
56
+ // rank of the builder's could ever land.
57
+ const g = {
58
+ passed: false, score: 0, threshold: 6, notes_md: '',
59
+ issues: [{ severity: 'blocker', file: 'migrations/099_x.sql',
60
+ summary: '[server permission-path] protected surface — an Archon must own it', worker: 'server-permission-path' }],
61
+ signals: { server_guard: { downgraded_to: 'fail', authorization_refusal: true,
62
+ tripped: [{ guard: 'permission_path', severity: 'blocker', summary: 'protected machinery' }] } },
63
+ };
64
+ const out = formatGradeOutcome(g, 1002614);
65
+ assert.match(out, /REFUSED \(authorization\)/, 'must label the outcome as an authorization refusal');
66
+ assert.match(out, /not a quality verdict/, 'must say the work was not judged and rejected');
67
+ assert.match(out, /migrations\/099_x\.sql/, 'names the refused file');
68
+ assert.doesNotMatch(out, /Fix the issues above/, 'must NOT teach fix-and-retry against a wall');
69
+ assert.doesNotMatch(out, /blended .*\/10/, 'a refusal is not a score');
70
+ });
71
+
72
+ test('the guard-name fallback also triggers the refusal shape (grades from pre-marker servers)', () => {
73
+ const g = {
74
+ passed: false, score: 0, threshold: 6, issues: [],
75
+ signals: { server_guard: { tripped: [{ guard: 'permission_path' }] } },
76
+ };
77
+ const out = formatGradeOutcome(g, 1);
78
+ assert.match(out, /REFUSED \(authorization\)/);
79
+ });
80
+
53
81
  test('a PASS prints the PASS block, no failure guidance (#943 regression guard)', () => {
54
82
  const g = {
55
83
  passed: true, score: 9, threshold: 6,
@@ -71,8 +71,12 @@ const TASK = { id: 996, title: 'fixture task', credits_reward: 15, kind: 'featur
71
71
  // ===========================================================================
72
72
 
73
73
  const savedGetLastClaimHolder = db.getLastClaimHolder;
74
- function stubHolder(holderId) {
74
+ const savedGetTask = db.getTask;
75
+ // taskExists backs the N-2 absent-task 404 (task 1002614): the gate consults
76
+ // db.getTask only when no claim holder exists.
77
+ function stubHolder(holderId, { taskExists = true } = {}) {
75
78
  db.getLastClaimHolder = async () => (holderId == null ? null : { builder_id: holderId });
79
+ db.getTask = async () => (taskExists ? { id: TASK.id } : undefined);
76
80
  }
77
81
  function mkReq(id, rank) {
78
82
  return { builder: { id, rank, github_login: `b${id}` } };
@@ -133,9 +137,10 @@ await test('gate (L2): an Archon may still file an override-request on a never-c
133
137
  assert.equal(res.statusCode, null);
134
138
  });
135
139
 
136
- // Restore the real lookup before the attribution layer (which uses the deps.pool
137
- // seam and never touches getLastClaimHolder).
140
+ // Restore the real lookups before the attribution layer (which uses the deps.pool
141
+ // seam and never touches getLastClaimHolder/getTask).
138
142
  db.getLastClaimHolder = savedGetLastClaimHolder;
143
+ db.getTask = savedGetTask;
139
144
 
140
145
  // ===========================================================================
141
146
  // Layer 2 — on approval, credit + tasks.shipped_by go to the CLAIM HOLDER.
@@ -41,8 +41,12 @@ const WORKER = 25; // the claim holder who did the work
41
41
  const OTHER = 99; // an unrelated builder
42
42
 
43
43
  // Stub the claim-holder lookup. holderId === null → no claim on record.
44
- function stubHolder(holderId) {
44
+ // taskExists backs the N-2 absent-task 404 (task 1002614): the gate consults
45
+ // db.getTask only when no claim holder exists, to tell a mistyped id from a
46
+ // never-claimed task.
47
+ function stubHolder(holderId, { taskExists = true } = {}) {
45
48
  db.getLastClaimHolder = async () => (holderId == null ? null : { builder_id: holderId });
49
+ db.getTask = async () => (taskExists ? { id: 123 } : undefined);
46
50
  }
47
51
 
48
52
  function mkReq(id, rank) {
@@ -108,6 +112,27 @@ await test('L2: a never-claimed task FAILS CLOSED for a non-Archon (the bypass-c
108
112
  assert.equal(res.body.error.code, 'not_your_task');
109
113
  });
110
114
 
115
+ await test('N-2 (task 1002614): a NONEXISTENT task id is a clean 404, not not_your_task', async () => {
116
+ // A sub-Metic who mistypes an id used to be told the task was not theirs —
117
+ // the same 403 as someone else's task — while an Archon saw a clean 404.
118
+ stubHolder(null, { taskExists: false });
119
+ const res = mkRes();
120
+ assert.equal(await gateTaskOwnership(mkReq(OTHER, 'thetes'), res, 999999), false);
121
+ assert.equal(res.statusCode, 404);
122
+ assert.equal(res.body.error.code, 'task_not_found');
123
+ });
124
+
125
+ await test('N-2: the 404 never overrides ownership — someone ELSE\'s task stays 403 even if getTask errs', async () => {
126
+ // The existence lookup runs ONLY when no claim holder exists: with a holder
127
+ // on record the gate must answer not_your_task without consulting getTask.
128
+ db.getLastClaimHolder = async () => ({ builder_id: WORKER });
129
+ db.getTask = async () => { throw new Error('must not be called when a holder exists'); };
130
+ const res = mkRes();
131
+ assert.equal(await gateTaskOwnership(mkReq(OTHER, 'thetes'), res, 123), false);
132
+ assert.equal(res.statusCode, 403);
133
+ assert.equal(res.body.error.code, 'not_your_task');
134
+ });
135
+
111
136
  await test('L2: a never-claimed task is still landable by an Archon (legit /confirm /grade path)', async () => {
112
137
  stubHolder(null);
113
138
  const res = mkRes();