@bongos/core 1.19.920 → 1.19.922
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 +71 -31
- package/.claude/skills/grade-audit/SKILL.md +9 -0
- package/docs/adr/0320-an-empty-diff-is-not-an-ungraded-diff.md +159 -0
- package/docs/adr/0321-brightness-is-a-weighted-share-and-the-targets-are-a-row.md +163 -0
- package/docs/adr/README.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/idea-brightness.js +212 -0
- package/modules/economy/idea-size-historical.js +22 -3
- package/modules/economy/idea-size.js +30 -3
- package/modules/grading/grader-no-artifact.js +347 -0
- package/modules/grading/grader.js +6 -0
- package/modules/lifecycle/cascade.js +36 -0
- package/modules/lifecycle/migrations/lifecycle_010_removal_targets.sql +116 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/removal-targets.js +217 -0
- package/modules/lifecycle/routes/lifecycle.js +9 -0
- package/modules/lifecycle/routes/removal-proposals.js +33 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-core-upgrade.js +26 -2
- package/scripts/gds/run-unit-tests.js +17 -1
- package/scripts/gds/ship-flow.js +54 -7
- package/scripts/gds/ship-grade-steps.js +43 -5
- package/scripts/gds/ship-grade.js +36 -0
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_runner.mjs +56 -0
- package/tests/git_merge_regen.mjs +87 -3
- package/tests/grader_no_artifact.mjs +360 -0
- package/tests/grader_prompt_fencing.mjs +46 -0
- package/tests/idea_brightness.mjs +550 -0
- package/tests/no_artifact_escalation.mjs +13 -3
- package/tests/skip_is_not_pass.mjs +91 -1
package/docs/adr/README.md
CHANGED
|
@@ -409,5 +409,7 @@ This keeps the decision history honest and traceable.
|
|
|
409
409
|
| 0316 | [**The ignite floor is a scheduling dial, not the trusted tier** ([task 1004084](https://cloudbongos.com/builders#/task/1004084), goal 1000095 — BONGOS-V2). BV2 decision 3 makes the ignite floor — who may turn a quick idea straight into claimable work — a per-instance setting, and decision 16 adds the descent decay rate beside it, the two to share one home. Both the spec and the seeded task described the mechanism the same way: the floor IS the `METIC_PLUS` constant in `modules/ideas/routing.js`, so this is "a setting, not a refactor". Tracing the code before building refuted that: `METIC_PLUS` is spelled once but READ for four semantically different gates — the two `decideLanding` bug branches (land at `ready` or at `backlog`), `mayProposeCriterion` (who may file a `kind='criterion-proposal'`, a planning-scope write), and the protected-scope wall in `authorizeRoutedLanding` (who may route work into the permission / ship-grade / deploy / migration core). Only the first two are the ignite floor, so making the CONSTANT configurable would mean an instance setting its floor to `xenos` — exactly the "project of one wants every thought claimable" case the owner asked for — ALSO gaining the right to file criterion-proposals and to breach the protected core. The codebase already said so in three places nobody had read into the spec: the constant's own header ("It grants nothing — the server-side rank floor and the goal's scope wall are separate, load-bearing gates"), the protected-scope branch ("the two are layers, not alternatives"), and `tests/filing_abuse_matrix.mjs`, which models the widening as an ATTACK and pins the literal. Decision: a separate dial. `modules/ideas/config.js` + `config/ideas.neutral.json` carry both knobs on the `modules/government/config.js` precedence (env -> instance root `config/ideas.json` -> core root neutral; the ADR 0268 dual-root split matters, since one root reads the host pack out of `node_modules` where `npm ci` erases it). `igniteFloor` defaults to `metic`, resolving to exactly the `METIC_PLUS` set, and is threaded into `decideLanding` as a ready-made `trustedRanks` **Set** by the callers — `routing.js` is TOLD the floor and never reads config, staying pure and DB-free — while `METIC_PLUS` stays a FIXED constant for the other two gates, neither of which takes a floor argument (pinned structurally). `descentDecayRate` defaults to 0.5 and ships with no production caller, because R11 is its intended reader and depends on the unbuilt R08 — the same deliberate slice boundary `modules/government/config.js` documents for itself, and what decision 16 asked for. Everything fails to VANILLA rather than dead: a malformed pack, an unknown rank or an out-of-band rate falls back, and an unrecognised floor yields the DEFAULT set, never an empty one (failing closed would stop every ignition on a typo). Rejected: configuring `METIC_PLUS` as written (a security widening in a config file's clothes); splitting it into four named constants (the other three want the same fixed tier, and naming them invites configuring them); having `routing.js` read the config itself (makes the landing matrix impure and untestable at a different floor). Spec decision 3's "spelled in one place" describes the intent, not the mechanism; the spec was not edited and the task description was amended in flight.](0316-the-ignite-floor-is-not-the-trusted-tier.md) | ideas / permissions |
|
|
410
410
|
| 0315 | [**A plane with no proxy is refused, not given a no-op ingress** ([task 1004017](https://cloudbongos.com/builders#/task/1004017), goal 1000106 — BONGOS-V2). The piece [ADR 0306](0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md) explicitly deferred: the self-host image ships no proxy, so provisioning step 6's `<slug>.caddy` snippet write and `systemctl reload caddy` cannot work there and a child instance gets no front door. **Two failure shapes, only one visible**: a DOMAINED child already half-refused, because `caddy adapt` fails with no binary and [ADR 0303](0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md)'s own catch turns that into a gap — but the message told the operator to add a directive to `/etc/caddy/Caddyfile`, a file the container does not have and that ADR 0303 §1 forbids provisioning from creating; a DOMAINLESS child skipped that preflight entirely (it is gated on `inst.domain`), reached step 6 and died on the `sudo` write, an uncaught throw that bypasses the `{ok:false}` pending-retry path and hard-errors the instance on attempt one — a crash recorded where a refusal was the honest answer. Decision: `ingressPlaneGap(exec)` probes the plane with `caddy version` — one cheap unprivileged read, the same posture as `checkCaddyWiring` — and both standup legs refuse with `Ingress preflight failed: …`, deliberately NOT domain-gated, because the domainless child is the one 0303 could not see. **Only a missing binary proves there is no proxy**: under remote exec `boxExec` is `ssh <host> bash -s`, so an unreachable co-hosting box throws here too, and diagnosing a network blip as 'this plane has no proxy, provision elsewhere' would be a confident wrong answer that burns the intent's retry budget — exit 127 is read as proof, anything else answers 'could not confirm a reverse proxy on this plane' with the real cause leading. The three refusals are ordered **capacity → ingress → wiring**, cheapest and most fundamental first and all before any mutation, which moves 0303's preflight behind the ADR 0285 capacity measurement; both still refuse before anything changes, so only the precedence between two refusals moves. **No ingress seam and no no-op backend**, despite the obvious symmetry with 0306: a `none` implementation whose `writeSite`/`reload` are no-ops lets the standup SUCCEED while leaving the child unreachable — neither half of the task's done-when, and exactly the silently-broken instance it exists to prevent. Does not decide: what a child's front door IS on a container plane (a published port, a bundled proxy, an explicit localhost-only shape) — a future ADR that adds one replaces this refusal rather than extending it. Teardown is left alone on purpose: it already reloads `allowFail`, and refusing there would strand a row instead of cleaning it.](0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md) | provisioning / ingress |
|
|
411
411
|
| 0317 | [**A plane that cannot give an instance its own account is refused, not given a shared uid** ([task 1004018](https://cloudbongos.com/builders#/task/1004018), goal 1000106 — BONGOS-V2). The last of the three container gaps [ADR 0306](0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md) deferred, and the sibling of [ADR 0315](0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md). [ADR 0281](0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md) makes an instance's identity its own unix account, and the unit's `User=`/`Group=` is what stops one co-tenant reading a sibling's `/proc/<pid>/environ` — where every instance's `EnvironmentFile` carries its GitHub App private key and OAuth client secret (audit B3). `useradd` needs uid 0; the self-host image is `node:22-bookworm-slim` running `USER node` with **no sudo**, so there the account cannot be created at all. **Two failure shapes, neither honest**: today step 1c's `boxExec(ensureInstanceUserCmd(...))` is not `allowFail`, so it throws AFTER every preflight passed, AFTER the status flip to `provisioning`, and — for a standalone — after a repo pull and an `npm ci`, recording a crash with the box half-changed; and tomorrow it goes quiet, because a compose self-host only stops short of step 1c by ADR 0315's coincidence, and 0315 says in its own consequences that a future front door **replaces** that refusal. Decision: `accountPlaneGap(exec, { privileged })` in `provision-repo.js` probes with `${sudo}sh -c 'test "$(id -u)" = 0'` and `provisionInstance` refuses with `Account preflight failed: …` before any mutation. **The probe runs the SAME escalation the mutation will** (`PROVISION_SUDO=1` renders the same `sudo ` prefix `ensureInstanceUserCmd` uses), so a pass is evidence rather than inference. **`sh -c`, not a bare `test`, is load-bearing**: under sudo the outer unprivileged shell would expand `$(id -u)` first and answer about the wrong user, passing on exactly the plane this refuses — and for the same reason there is no pipeline, whose exit status is its last stage's. **Only a shell that ran and answered proves it**: `boxExec` is `ssh <host> bash -s` under remote exec, so exit 1 (`test` said no) and 127 (no sudo binary) are read as proof while ssh's own 255 answers "could not confirm" with the real cause leading — ADR 0315's rule applied to a different question. Ordered **third**, after capacity and ingress and before 0303's wiring check: the first three ask about the PLANE, 0303 asks about this instance's DOMAIN, and 0315's order is extended rather than rearranged. **Rejected: accepting it** — documenting a self-host container as single-tenant with a shared uid is a silent downgrade of the vector 0281 closed, observable nowhere on the running box, and "single-tenant" is not what a container hosting several child projects is; **an identity seam with a container implementation**, because nothing exists to put behind it and a seam whose second backend does nothing is 0315's no-op wearing a seam's clothes; **probing `useradd --help` / `command -v useradd`**, which succeed unprivileged on the very image this refuses; **`${sudo}id -u` compared against `0`**, which decides on captured stdout where every sibling preflight and every test fake treats exit status as the contract; and **refusing inside step 1c**, which keeps the refusal after the status flip. Not covered, deliberately: root-but-no-`useradd` (an Alpine-style plane fails loudly at step 1c as today), and teardown (0315's reason — a refusal there strands a row instead of cleaning it). No `PROVISION_ALLOW_SHARED_UID`: a runner either reaches root on this plane or it does not.](0317-a-plane-that-cannot-give-an-instance-its-own-account-is-refused.md) | provisioning / identity |
|
|
412
|
+
| 0320 | [**An empty diff is not an ungraded diff: grade the claim instead** ([task 1004011](https://cloudbongos.com/builders#/task/1004011), goal 1000118 — BONGOS-V2; scoped out of task 1003950 shape 2). `ship.js --allow-empty` closes a task that produced no committed artifact, and it used to do two things at once: set `skipGrade`, so the subagent grader never ran, and therefore route the close through `POST /tasks/:id/confirm`, gated on `task.confirm.grade_bypass` — one of [ADR 0157](0157-archon-is-rank-and-identity-only.md)'s four trust-boundary keys, so a Metic could not close an artifact-free task at all. **Both halves were wrong.** The permission was the wrong question: `grade_bypass` means *ratify past a grade that FAILED*, a genuine override of an existing verdict, and a no-artifact close has no verdict to override — charging the same key for "it was judged and rejected" and "there was nothing to judge" conflates an override with an absence. And nothing was being checked, which is the more serious half: the assertions in the handoff notes ("the premise was already fixed", "this was a pure ops verification", "the row was updated directly") went into the ledger unread, on exactly the ships where the record is the ONLY evidence that exists. A permission gate is not scrutiny — an Archon clicking confirm is not someone checking whether the claim is true. **Decision:** on `--allow-empty` the grader is not skipped, it is RE-POINTED — it grades the notes + value summary against the task description, asking whether the task is genuinely artifact-free and whether the evidence supports that. A pass is a real passing grade confirming through the ordinary rank-open path; a fail is answered with better evidence, not a key. Three sub-decisions carry it: the rubric axes are **re-asked, not replaced** (`functional_fidelity` → is this genuinely artifact-free, `code_quality` → is the evidence specific and checkable), because the gate arithmetic and `/grade-audit`, the aggregates and the cost model all read those columns and a third pair of names would mean teaching every one of them a synonym; **two generalist shots, not the four-lens panel** (Hacker/Quality/Efficiency are lenses over code — over three paragraphs of prose they are four readings of the same short text at four times the cost — so the lenses go, recorded in `signals.lenses_dropped` per the #513 trivial-diff precedent; but the panel's UNANIMOUS gate was also the cross-check that makes injection hard, and the first cut kept a single shot and lost it, so the species asks the same question twice and collapses by **unanimity, not median** — under a median the mean of a fooled 10 and a suspicious 3 is a passing 6.5 and the second reading launders the first; a split is recorded as `signals.shot_dissent`; the collapse covers ALL THREE gate axes including `confidence`, which the second grading round caught missing — it is the one axis living in `signals` rather than `rubric_scores`, and the server derives `lowConfidence` purely from the submitted value, so an honest 0.2 "I cannot judge this" averaged with a confident 0.9 would have cleared the 0.4 gate at 0.55); and the species marker lives in **`signals.no_artifact_grade`, not `grader_kind`**, because the server's `validateAndScore` hardcodes `task_grades.grader_kind` to `'subagent'` for everything on that route. **D4: every builder-controlled field is scrubbed and fenced** (`grader-workers/fence.js`, SR-15 / #995) — the panel workers have fenced the diff, title, description and value summary since #995, and this species is the worse case on both sides: the notes are not context around an artifact the model can independently check, they ARE the evidence, and a successful injection does not flip one of four workers, it closes a task past the very scrutiny this ADR added to replace the Archon-only gate. The first implementation shipped it unfenced and the grader caught it as a blocker. The no-follow rule is restated AFTER the artifact too, since the notes may run to 24,000 characters and a rule stated once at the top is a long way behind by the end of an adversarial payload. The prompt renders no changed-files or diff section at all, so the absence cannot read as the finding. No diff hash is stored — hashing the empty diff makes it a reuse key shared by every empty-diff ship of the task, handing the next close a pass this one earned. Unchanged: `--skip-grade` still reaches the Archon gate (the task 1003950 escalation split still chooses the refusal), and below the protected-path floor the server's Guard 1b (task 1002646) still fails a zero-`committed_files` pass closed, correctly — this moves the Metic-and-above case, which is the case that was filed. **Drops task 1004010**, the alternative shape — a new lower-floored permission atom for the same close — by owner decision of 2026-09-20: it answers "who may skip the check" when the better question is "why is there no check", and it permanently widens the permission surface, the same class of change the owner declined three days earlier for `version.create` ([task 1003853](https://cloudbongos.com/builders#/task/1003853), [ADR 0250](0250-strict-versioning-the-version-boundary-is-the-scope-gate.md) §6a). Also rejected: lowering the floor on the existing key (treats the gate as the point; nothing would read the notes); grading the notes with the full panel; a new pair of rubric axes for prose (honest about the measurement, but moves the gate arithmetic and every consumer of it for a naming gain).](0320-an-empty-diff-is-not-an-ungraded-diff.md) | grading / permissions |
|
|
412
413
|
| 0319 | [**A removal verdict is a row beside the question, because both answers close it the same way** ([task 1004095](https://cloudbongos.com/builders#/task/1004095), goal 1000095 — BONGOS-V2). [ADR 0313](0313-a-removal-is-proposed-by-the-ship-and-confirmed-by-a-person.md) settled that a ship deleting an earlier shipped task’s files files a QUESTION and a person answers it; task 1004090 built the question and nothing could answer it. Building the answer surfaced the property that decides the design: a proposal is a question, not work, so CONFIRMING and DISMISSING both leave nothing to build and both end the task at `abandoned` — meaning a status alone records neither, and brightness could not tell the star it must dim from the one it must not. ADR 0313 had already rejected the obvious alternative (a `removed` status; `shipped` is terminal and much of the ledger leans on it — a removal is a fact recorded BESIDE work, not a new terminal state). Decision: `lifecycle_removal_verdicts` (migration `lifecycle_009`), PRIMARY KEY on `proposal_task_id` so one proposal holds one answer ever — a second decide is REFUSED (409), never upserted and never an idempotent 200, because overwriting destroys the record that a person answered differently; the row is locked `FOR UPDATE` first, so simultaneous Confirm and Dismiss cannot both read “undecided”, and `ALREADY_DECIDED` is checked BEFORE `PROPOSAL_CLOSED` because an answered proposal is also abandoned and the useful message is the one naming the earlier verdict. The verdict and the status change commit in ONE transaction. What this does NOT ship is the target list: the affected tasks and paths stay PROSE in the proposal body (the owner’s 2026-09-20 decision, since [ADR 0242](0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md) forbids widening the runner to fan out), and nothing here parses it — brightness designs that storage. What brightness does get is structured: the verdict plus `ship_task_id`, denormalised out of the `source_ref` that encodes it as a string nothing can join on. Surface: spec decision 15’s “existing awaiting-nod surface” is the CARD, not the QUEUE — [ADR 0299](0299-the-nod-queue-stays-scoped-to-routed-filings.md) keeps that queue scoped to routed filings and a removal proposal has no idea lineage at all, so it gets its own slice on the same card at the same floor. The PERMISSION is reused (`task.promote`, Metic+, read and write both) and the VERB is not: no existing route means “that work is genuinely gone”, and `/tasks/:id/abandon` would close the question without recording which answer was given. Top-level `/removal-proposals` rather than `/tasks/removal-proposals`, because the manifest mounts `tasks` first and its `/tasks/:id` swallows a later file’s literal segment. Rot is INHERITED: `backlog` is rottable and `abandoned` is not, so an unanswered proposal rots and an answered one leaves the feed with no code written for either — pinned against the rot module’s own exported vocabulary. Rejected: the status as the record; parsing the body at confirm time; widening the nod queue; separate `/confirm` and `/dismiss` routes; upserting a second verdict; a `LIKE ‘prefix%’` match (a rule key may contain an underscore, which is a LIKE wildcard).](0319-a-removal-verdict-is-a-row-beside-the-question.md) | ideas / lifecycle |
|
|
413
414
|
| 0318 | [**Listing a file for task affinity is not claiming to own it** ([task 1004110](https://cloudbongos.com/builders#/task/1004110), goal 1000095 — BONGOS-V2). The case [ADR 0314](0314-module-protection-is-per-path-not-per-module.md) named and left open, closed by neither of the two routes it predicted. `builder-settings` derived `protected` — Archon-only to scope a goal to — on exactly one glob, `src/bongos/routes/me.js`, caught by the blanket `src/bongos/routes/` prefix. **That glob was never an ownership claim:** `MODULE_GLOBS` listed it for task AFFINITY and said in the same comment that the `GET /me` aggregator stays a thin core facade, and `isProtected()` cannot tell the two apart. **Both predicted routes were measured and rejected.** Narrowing the blanket prefix: all eighteen files under `src/bongos/routes/` are protected by that prefix AND NOTHING ELSE, so narrowing unwalls `auth.js`, `builders.js`, `backup.js` and `core-update.js` together — now pinned as a tripwire test rather than left as a caution. Moving `me.js` into `modules/builder-settings/`: the aggregator resolves TEN module ports (`lifecycle`, `reward`, `onboarding`, `government`, `specialities`, `grade`, `box`, `art` ×2, `discord`) and `builder-settings` is one of them, so hosting it inside one module means resolving nine foreign ports from the inside — the `core -> module` edge [ADR 0083](0083-modular-architecture-strangler-build-plan.md) rule 1 forbids. Decision: **ownership follows what the file is** — `me.js` moves to `kernel`, which already owns `src/bongos/routes/security.js`. This is 0314 one step on: owning the command is not owning the authority, and **being listed next to a module is not owning it either**. **Rejected: a general affinity tier** in `MODULE_GLOBS` mirroring `isCommandPath` — the audit does not support it. Three other modules (`kernel`, `devsecops`, `methodology`) are also protected only by paths outside their own directory, but in each the foreign path genuinely IS the domain; `builder-settings` was the only module protected by a path its own source called core, so a new tier would be a new place for a hole to hide, built for a population of one. `protectedModules()` goes 9 -> 8. **The wall that matters does not move:** a sub-Metic builder touching `/me` is still refused by `checkPermissionPaths`, asserted directly rather than assumed. The sixteen still-unowned route files remain [task 1004116](https://cloudbongos.com/builders#/task/1004116), whose option (a) this de-risks.](0318-listing-a-file-for-task-affinity-is-not-owning-it.md) | permissions / module scope |
|
|
415
|
+
| 0321 | [**Brightness is a payout-weighted share, and a removal's targets are rows** ([task 1004099](https://cloudbongos.com/builders#/task/1004099), goal 1000095 — BONGOS-V2). Size is an idea's total ideator payout and never falls; brightness is how much of that work is still alive, and the pair is what lets a thought whose version was torn out draw LARGE AND DARK rather than merging with one that never mattered. Two things had to be settled. **The removal facts were stored in the wrong direction**: [ADR 0319](0319-a-removal-verdict-is-a-row-beside-the-question.md) gave a confirmed removal a row carrying the verdict and `ship_task_id` — the task whose merge DELETED the files — and deliberately left out the tasks whose work was removed, saying brightness would design its own storage when it needed one. Those are different rows under different ideas, so reading one as the other dims the wrong star while looking like a working query. `<redacted>` stores `(proposal_task_id, removed_task_id, paths)` at PROPOSAL time, from the SAME derivation objects the proposal body renders as prose — one derivation, two renderings, so they cannot disagree and `removal-proposals.js` keeps its refusal to parse a body back. Proposal time and not verdict time because the attribution comes from capped, rate-limited GitHub commit-history reads that only exist while the cascade runs. NOT a runner fan-out ([ADR 0242](0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md)): the rule still files one task; this records a side fact about it. `ON CONFLICT DO NOTHING`, never `DO UPDATE`, since a degraded second walk finds FEWER paths and must not erase a good first read — and the writer runs on the cascade's SKIPPED row too, so a re-ship repairs what a derivation outage missed. **The share is weighted by PAYOUT, not by task count**, or deleting a one-line fix dims a star as hard as deleting the feature; weighting by what each hop earned also makes 'large and dead' exact, and makes the two channels a share of ONE number rather than two unrelated measurements. Basis is paid + estimated to match size, which required `priceDescentForIdea`/`ideaSizes` to return a per-task `byTask` summing EXACTLY to `credits` — including ledger payments for tasks the descent no longer reaches, which `idea-size.js` keeps in `credits` so size cannot fall and which would otherwise make the parts sum to less than the whole. Three edges, each silent: an idea that earned nothing is `share: 1, hasWork: false`, because zero over zero is not zero and a protostar is not a remnant (the BV2.R19 distinction); an unreadable removal port gives `share: null, attributable: false`, because 'could not look' is not 'nothing was removed'; and a CONFIRMED removal naming no target dims nothing at all — brightness cannot know which idea — so `countUnattributedConfirmed()` exists to say the gap out loud instead of quietly brightening the sky. Brightness lives in `economy` beside size and reaches lifecycle through the new `lifecycle.removals` port, resolved OPTIONALLY and NOT declared in `consumes` (declaring it would turn 'lifecycle off' into 'instance will not start' — the `ideas.descent` precedent). **No credit is clawed back and nothing here writes**: ADR 0313 stands, `shipped` is terminal, only the picture changes. Rejected: parsing the proposal body (ADR 0319 already refused it — a human-facing line shape fails silently when reworded); re-deriving at verdict time; treating `ship_task_id` as the removed task; counting tasks rather than payouts; clamping the share at the top, which would hide the anomaly it claims to prevent.](0321-brightness-is-a-weighted-share-and-the-targets-are-a-row.md) | ideas / economy |
|
|
@@ -2314,5 +2314,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2314
2314
|
landed since 1.19.918 with no explicit bump. run 35531198454. (task 1002620)
|
|
2315
2315
|
1.19.920 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2316
2316
|
landed since 1.19.919 with no explicit bump. run 35531720944. (task 1002620)
|
|
2317
|
+
1.19.921 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2318
|
+
landed since 1.19.920 with no explicit bump. run 35534076467. (task 1002620)
|
|
2319
|
+
1.19.922 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2320
|
+
landed since 1.19.921 with no explicit bump. run 35536293721. (task 1002620)
|
|
2317
2321
|
---------------------------------------------------------------------------
|
|
2318
2322
|
```
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/economy/idea-brightness.js — BRIGHTNESS: the live share of an idea's
|
|
4
|
+
// descent (task 1004099 / BV2.R16, goal 1000095; spec
|
|
5
|
+
// docs/specs/bongos-v2-ideator-surface.md decisions 4 and 20; ADR 0321, under
|
|
6
|
+
// ADR 0313 + 0319).
|
|
7
|
+
//
|
|
8
|
+
// WHAT BRIGHTNESS IS. Size is an idea's total ideator payout from its descent and
|
|
9
|
+
// NEVER falls (./idea-size.js). Brightness is how much of that payout is still
|
|
10
|
+
// alive: the same sum, restricted to the descent tasks the project has not since
|
|
11
|
+
// confirmed as removed, over the whole. Two channels, two facts — and the pair is
|
|
12
|
+
// the entire point. A thought that caused a version the project later tore out
|
|
13
|
+
// draws LARGE AND DARK, which is visibly different from a thought that never
|
|
14
|
+
// caused anything and draws small and dark. One number could not say that.
|
|
15
|
+
//
|
|
16
|
+
// GRADED, NOT BINARY (spec decision 20). A descent of five things with three
|
|
17
|
+
// still alive is partly lit. It goes out only when nothing survives. So this is a
|
|
18
|
+
// share in [0, 1] and not a flag — an idea dims progressively as pieces of its
|
|
19
|
+
// work are removed.
|
|
20
|
+
//
|
|
21
|
+
// WEIGHTED BY PAYOUT, NOT BY COUNT, and that is the decision in this file.
|
|
22
|
+
// Counting tasks would make removing a one-line fix dim a star as much as
|
|
23
|
+
// removing the feature it belongs to. Weighting by what each hop actually earned
|
|
24
|
+
// means brightness falls in proportion to how much of the idea's WORTH died,
|
|
25
|
+
// which is the same quantity size is drawn from — so the two channels are a
|
|
26
|
+
// share of one number rather than two unrelated measurements that happen to sit
|
|
27
|
+
// side by side. It also makes "large and dead" exact: full size, zero brightness.
|
|
28
|
+
//
|
|
29
|
+
// THE BASIS IS PAID + ESTIMATED, the same as size. A historical idea's descent
|
|
30
|
+
// was never paid for (./idea-size-historical.js prices those hops), and if
|
|
31
|
+
// brightness counted only banked credits, removing an estimated hop would dim
|
|
32
|
+
// nothing while the size drawn beside it still counted that hop. The share would
|
|
33
|
+
// then be a fraction of a different number than the one on screen.
|
|
34
|
+
//
|
|
35
|
+
// BRIGHTNESS NEVER CLAWS BACK CREDITS, and nothing here could. This module only
|
|
36
|
+
// READS — no writer, no ledger row, no status change. ADR 0313 settles that
|
|
37
|
+
// `shipped` is terminal and the ledger is never rewritten when the project
|
|
38
|
+
// changes its mind: only the picture changes. Size is computed from credit_log
|
|
39
|
+
// and is untouched by anything in this file.
|
|
40
|
+
//
|
|
41
|
+
// WHAT COUNTS AS REMOVED IS A PERSON'S ANSWER, never a derived one. A ship that
|
|
42
|
+
// deletes an earlier shipped task's files files a QUESTION (the
|
|
43
|
+
// `removal-proposed-on-ship` cascade), someone confirms or dismisses it, and only
|
|
44
|
+
// 'confirmed' reaches this read. Refactors, renames and file moves all look like
|
|
45
|
+
// deletions to a diff, so a derived-only signal would dim the sky continuously —
|
|
46
|
+
// ADR 0313's central finding, and the reason `dismissed` exists at all.
|
|
47
|
+
//
|
|
48
|
+
// MODULE BOUNDARY: the removal facts live in the LIFECYCLE module and are reached
|
|
49
|
+
// through the kernel-mediated `lifecycle.removals` port, never a cross-module
|
|
50
|
+
// require (ADR 0083 rule 1) — the same way ./idea-size.js reaches `ideas.descent`.
|
|
51
|
+
|
|
52
|
+
const api = require('../../src/module-api');
|
|
53
|
+
const { resolveOptional } = api;
|
|
54
|
+
const { ideaSizes } = require('./idea-size');
|
|
55
|
+
|
|
56
|
+
// A positive integer id as it arrives off a row or a caller, where it may be a
|
|
57
|
+
// bigint serialised to a string. Never coerced with Number(); the guard
|
|
58
|
+
// ./idea-size.js uses, for the same precision reason.
|
|
59
|
+
function idOf(v) {
|
|
60
|
+
return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ideaBrightnesses — brightness for MANY ideas, keyed by idea id (string).
|
|
64
|
+
//
|
|
65
|
+
// Returns Map<ideaIdString, {
|
|
66
|
+
// share number in [0,1] — the live fraction of the descent's payout,
|
|
67
|
+
// or null when it could not be established (see below)
|
|
68
|
+
// live credits still alive
|
|
69
|
+
// removed credits whose work is confirmed gone
|
|
70
|
+
// credits the total the two divide (equals size's `credits`)
|
|
71
|
+
// removedTasks [{ taskId, credits, proposalTaskId, paths }] — WHAT went out,
|
|
72
|
+
// so a surface can say why a star dimmed instead of only that it
|
|
73
|
+
// did
|
|
74
|
+
// hasWork false when the idea has earned nothing at all
|
|
75
|
+
// attributable false when the removal facts could not be read
|
|
76
|
+
// }>
|
|
77
|
+
//
|
|
78
|
+
// EVERY ID ASKED FOR IS PRESENT. Absent and dark are different answers, and an
|
|
79
|
+
// idea that caused nothing yet is a real state the sky has to draw.
|
|
80
|
+
//
|
|
81
|
+
// AN IDEA THAT EARNED NOTHING IS `share: 1`, `hasWork: false` — LIT, NOT DARK,
|
|
82
|
+
// and this is the sharpest edge in the file. Zero over zero is not zero. A
|
|
83
|
+
// freshly-filed thought has had nothing removed from it; it is a protostar, not a
|
|
84
|
+
// remnant. Returning 0 would light-for-light merge it with the thought whose
|
|
85
|
+
// version was torn out, which is exactly the distinction BV2.R19's proof exists
|
|
86
|
+
// to check. `hasWork` is what lets the five states (BV2.R17) tell the two apart
|
|
87
|
+
// without re-deriving it.
|
|
88
|
+
//
|
|
89
|
+
// WHEN THE REMOVAL FACTS CANNOT BE READ — the lifecycle module is off, or the
|
|
90
|
+
// port is unregistered — `share` is NULL and `attributable` is false, rather than
|
|
91
|
+
// 1. "Nothing was removed" and "we could not look" are different answers and only
|
|
92
|
+
// one of them may be drawn as a plain full-brightness star. Same posture
|
|
93
|
+
// ./idea-size.js takes with `historical: null` when the descent walk is
|
|
94
|
+
// unavailable.
|
|
95
|
+
//
|
|
96
|
+
// ideaIds — array of ids (bigint-safe strings or numbers)
|
|
97
|
+
// client — optional tx client; falls back to the pool inside the reads
|
|
98
|
+
// descentByIdea — the caller's already-walked descent, passed straight through
|
|
99
|
+
// to ideaSizes so a page that has one costs no extra walk
|
|
100
|
+
// sizes — an already-computed ideaSizes() result, when the caller has
|
|
101
|
+
// one. Brightness needs the same per-task figures size does,
|
|
102
|
+
// so a surface drawing both should compute sizes once and hand
|
|
103
|
+
// them here rather than paying for the whole read twice.
|
|
104
|
+
async function ideaBrightnesses(ideaIds, { client = null, descentByIdea = null, sizes = null } = {}) {
|
|
105
|
+
const ids = [...new Set((Array.isArray(ideaIds) ? ideaIds : []).map(idOf).filter(Boolean))];
|
|
106
|
+
const out = new Map();
|
|
107
|
+
if (ids.length === 0) return out;
|
|
108
|
+
|
|
109
|
+
const sized = sizes || await ideaSizes(ids, { client, descentByIdea });
|
|
110
|
+
|
|
111
|
+
// The per-task breakdown. `ideaSizes` carries the totals; `byTask` is what
|
|
112
|
+
// lets a share be taken of them, and it is absent only when the descent could
|
|
113
|
+
// not be walked at all — in which case there is nothing to attribute against
|
|
114
|
+
// and the idea reads unattributable below.
|
|
115
|
+
const perTask = new Map();
|
|
116
|
+
for (const id of ids) {
|
|
117
|
+
const s = sized.get(id);
|
|
118
|
+
perTask.set(id, (s && s.byTask instanceof Map) ? s.byTask : null);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Every descent task across every idea asked for, in ONE port call. A lookup
|
|
122
|
+
// per idea would be a round trip per star on a page of them.
|
|
123
|
+
const allTaskIds = new Set();
|
|
124
|
+
for (const byTask of perTask.values()) {
|
|
125
|
+
if (!byTask) continue;
|
|
126
|
+
for (const taskId of byTask.keys()) allTaskIds.add(taskId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const port = resolveOptional && resolveOptional('lifecycle.removals');
|
|
130
|
+
let confirmed = null;
|
|
131
|
+
if (port && typeof port.confirmedRemovalsForTasks === 'function' && allTaskIds.size > 0) {
|
|
132
|
+
confirmed = await port.confirmedRemovalsForTasks([...allTaskIds], { client });
|
|
133
|
+
} else if (port && typeof port.confirmedRemovalsForTasks === 'function') {
|
|
134
|
+
// The port is there and there is simply nothing to ask about. That is an
|
|
135
|
+
// established answer — no removals — not an unreadable one.
|
|
136
|
+
confirmed = new Map();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const id of ids) {
|
|
140
|
+
const s = sized.get(id) || { credits: 0 };
|
|
141
|
+
const byTask = perTask.get(id);
|
|
142
|
+
const credits = Number(s.credits) || 0;
|
|
143
|
+
|
|
144
|
+
if (!confirmed || !byTask) {
|
|
145
|
+
out.set(id, {
|
|
146
|
+
share: null, live: null, removed: null, credits,
|
|
147
|
+
removedTasks: [], hasWork: credits > 0, attributable: false,
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let removed = 0;
|
|
153
|
+
const removedTasks = [];
|
|
154
|
+
for (const [taskId, taskCredits] of byTask) {
|
|
155
|
+
const hit = confirmed.get(taskId);
|
|
156
|
+
if (!hit) continue;
|
|
157
|
+
const c = Number(taskCredits) || 0;
|
|
158
|
+
removed += c;
|
|
159
|
+
removedTasks.push({
|
|
160
|
+
taskId,
|
|
161
|
+
credits: c,
|
|
162
|
+
proposalTaskId: hit.proposalTaskId,
|
|
163
|
+
paths: hit.paths,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The live half comes off the TOTAL minus what died, not off a second sum
|
|
168
|
+
// over the surviving tasks. The two differ: `credits` includes payouts for
|
|
169
|
+
// tasks the descent no longer reaches (a re-parented task, a deleted edge),
|
|
170
|
+
// which ./idea-size.js keeps on purpose so size cannot fall. Summing the
|
|
171
|
+
// survivors would quietly drop those and make a re-parenting dim a star.
|
|
172
|
+
const live = credits - removed;
|
|
173
|
+
// Clamped at the bottom only, and never at the top. `removed` is a subset of
|
|
174
|
+
// the same figures `credits` was built from so it cannot exceed it — but if
|
|
175
|
+
// it ever did, a negative brightness is a rendering bug where a zero is a
|
|
176
|
+
// legible "nothing survives". The anomaly is still visible in `live` and
|
|
177
|
+
// `removed`, which are reported unclamped.
|
|
178
|
+
const share = credits > 0 ? Math.max(0, live) / credits : 1;
|
|
179
|
+
|
|
180
|
+
out.set(id, {
|
|
181
|
+
share,
|
|
182
|
+
live,
|
|
183
|
+
removed,
|
|
184
|
+
credits,
|
|
185
|
+
// Largest loss first: a surface explaining why a star dimmed wants the
|
|
186
|
+
// piece that mattered, not the first one the descent happened to yield.
|
|
187
|
+
removedTasks: removedTasks.sort((a, b) => b.credits - a.credits),
|
|
188
|
+
hasWork: credits > 0,
|
|
189
|
+
attributable: true,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ideaBrightness — the same answer for one idea. A thin wrapper on purpose: two
|
|
196
|
+
// implementations of "how lit is this thought" is two answers, and this is the
|
|
197
|
+
// shape a single-idea panel wants. Mirrors ./idea-size.js's ideaSize.
|
|
198
|
+
async function ideaBrightness(ideaId, opts = {}) {
|
|
199
|
+
const key = idOf(ideaId);
|
|
200
|
+
const unknown = {
|
|
201
|
+
share: null, live: null, removed: null, credits: 0,
|
|
202
|
+
removedTasks: [], hasWork: false, attributable: false,
|
|
203
|
+
};
|
|
204
|
+
if (!key) return unknown;
|
|
205
|
+
const all = await ideaBrightnesses([key], opts);
|
|
206
|
+
return all.get(key) || unknown;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
ideaBrightness,
|
|
211
|
+
ideaBrightnesses,
|
|
212
|
+
};
|
|
@@ -56,7 +56,11 @@ const {
|
|
|
56
56
|
// decay — the resolved per-instance dial (reward-policy ideaDescentDecay)
|
|
57
57
|
// paidByTask — Map<taskIdString, creditsPaid> from credit_log, per task
|
|
58
58
|
//
|
|
59
|
-
// Returns { paid, estimated, payments, estimatedPayments }
|
|
59
|
+
// Returns { paid, estimated, payments, estimatedPayments, byTask }, where
|
|
60
|
+
// `byTask` is Map<taskIdString, credits> — the per-hop breakdown brightness
|
|
61
|
+
// weights a descent by (task 1004099 / BV2.R16). The early return on an
|
|
62
|
+
// unusable decay yields an EMPTY map rather than no map, so a caller never has
|
|
63
|
+
// to test for its absence.
|
|
60
64
|
//
|
|
61
65
|
// ONLY A SHIPPED TASK COUNTS. The payout fires at ship, so a ready or abandoned
|
|
62
66
|
// descendant has earned its origin nothing and estimating for it would credit
|
|
@@ -75,7 +79,19 @@ const {
|
|
|
75
79
|
// what the same descent would have been paid hop by hop, which is the whole
|
|
76
80
|
// thing this function exists to reproduce.
|
|
77
81
|
function priceDescentForIdea({ tasks, grade, completeness, decay, paidByTask } = {}) {
|
|
78
|
-
|
|
82
|
+
// `byTask` — the same arithmetic, kept per task instead of only summed
|
|
83
|
+
// (task 1004099 / BV2.R16). Brightness is the LIVE SHARE of a size, so it has
|
|
84
|
+
// to weight each descent task by what that task contributed, and the totals
|
|
85
|
+
// alone cannot be taken apart again afterwards. Added here rather than
|
|
86
|
+
// recomputed in brightness because a second copy of this loop is a second
|
|
87
|
+
// answer to "what is this hop worth", and the two would drift the first time
|
|
88
|
+
// a decay dial or a grade factor moved.
|
|
89
|
+
//
|
|
90
|
+
// Map<taskIdString, credits>, carrying BOTH kinds — a paid hop at its ledger
|
|
91
|
+
// figure and an estimated hop at its priced one. Brightness must use the same
|
|
92
|
+
// basis as size or the share would be a fraction of a different number than
|
|
93
|
+
// the one drawn beside it.
|
|
94
|
+
const out = { paid: 0, estimated: 0, payments: 0, estimatedPayments: 0, byTask: new Map() };
|
|
79
95
|
const list = Array.isArray(tasks) ? tasks : [];
|
|
80
96
|
const paidMap = paidByTask instanceof Map ? paidByTask : new Map();
|
|
81
97
|
|
|
@@ -98,8 +114,10 @@ function priceDescentForIdea({ tasks, grade, completeness, decay, paidByTask } =
|
|
|
98
114
|
seen.add(key);
|
|
99
115
|
|
|
100
116
|
if (paidMap.has(key)) {
|
|
101
|
-
|
|
117
|
+
const banked = Number(paidMap.get(key)) || 0;
|
|
118
|
+
out.paid += banked;
|
|
102
119
|
out.payments += 1;
|
|
120
|
+
out.byTask.set(key, banked);
|
|
103
121
|
continue;
|
|
104
122
|
}
|
|
105
123
|
|
|
@@ -113,6 +131,7 @@ function priceDescentForIdea({ tasks, grade, completeness, decay, paidByTask } =
|
|
|
113
131
|
if (delta <= 0) continue;
|
|
114
132
|
out.estimated += delta;
|
|
115
133
|
out.estimatedPayments += 1;
|
|
134
|
+
out.byTask.set(key, delta);
|
|
116
135
|
}
|
|
117
136
|
return out;
|
|
118
137
|
}
|
|
@@ -152,7 +152,9 @@ function idOf(v) {
|
|
|
152
152
|
// row is stream 2 — the thought, not the work it caused.
|
|
153
153
|
//
|
|
154
154
|
// Returns Map<ideaIdString, { credits, paid, estimated, payments, scale,
|
|
155
|
-
// historical }
|
|
155
|
+
// historical, byTask }>, where `byTask` is Map<taskIdString, credits> summing to
|
|
156
|
+
// `credits` — the per-hop breakdown brightness (./idea-brightness.js, BV2.R16)
|
|
157
|
+
// takes its live share of. Every id asked for is present, at zero when it has been paid
|
|
156
158
|
// nothing: absent and zero are different answers to a renderer, and an idea that
|
|
157
159
|
// caused nothing yet is a real state the surface has to draw, not a gap.
|
|
158
160
|
//
|
|
@@ -184,7 +186,10 @@ async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {})
|
|
|
184
186
|
const ids = [...new Set((Array.isArray(ideaIds) ? ideaIds : []).map(idOf).filter(Boolean))];
|
|
185
187
|
const out = new Map();
|
|
186
188
|
for (const id of ids) {
|
|
187
|
-
|
|
189
|
+
// `byTask` empty rather than absent: an idea with no payouts has a real,
|
|
190
|
+
// established breakdown (nothing), and a caller should not have to tell
|
|
191
|
+
// that apart from a field that was never filled in.
|
|
192
|
+
out.set(id, { credits: 0, paid: 0, estimated: 0, payments: 0, scale: 0, historical: false, byTask: new Map() });
|
|
188
193
|
}
|
|
189
194
|
if (ids.length === 0) return out;
|
|
190
195
|
|
|
@@ -235,6 +240,11 @@ async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {})
|
|
|
235
240
|
out.set(id, {
|
|
236
241
|
credits: acc.total, paid: acc.total, estimated: null,
|
|
237
242
|
payments: acc.payments, scale: compressIdeaSize(acc.total), historical: null,
|
|
243
|
+
// The ledger's own per-task figures are still true even with no descent
|
|
244
|
+
// in hand, and they sum to `credits` here because `credits` IS the
|
|
245
|
+
// ledger total on this branch. Brightness can therefore still take a
|
|
246
|
+
// share; it simply cannot see hops that were never paid for.
|
|
247
|
+
byTask: acc.byTask,
|
|
238
248
|
});
|
|
239
249
|
continue;
|
|
240
250
|
}
|
|
@@ -251,6 +261,22 @@ async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {})
|
|
|
251
261
|
// edge) is still money this idea was paid, and dropping it would make size
|
|
252
262
|
// fall.
|
|
253
263
|
const credits = acc.total + priced.estimated;
|
|
264
|
+
// THE PER-TASK BREAKDOWN, for brightness (task 1004099 / BV2.R16), and it
|
|
265
|
+
// has to SUM TO `credits` or the share taken of it is a fraction of the
|
|
266
|
+
// wrong number. Two sources, in this order:
|
|
267
|
+
//
|
|
268
|
+
// • `priced.byTask` — every hop the descent reaches, at its ledger figure
|
|
269
|
+
// where one was paid and at its priced figure where it was estimated.
|
|
270
|
+
// • then any ledger payment for a task the descent NO LONGER REACHES — a
|
|
271
|
+
// re-parented task, a deleted edge. Those are in `acc.total` (kept there
|
|
272
|
+
// on purpose, a few lines up, so size cannot fall), so leaving them out
|
|
273
|
+
// here would make the parts sum to less than the whole and quietly
|
|
274
|
+
// overstate brightness. `priced.byTask` wins on a collision because a
|
|
275
|
+
// paid hop carries the same banked figure in both.
|
|
276
|
+
const byTask = new Map(priced.byTask);
|
|
277
|
+
for (const [taskId, paid] of acc.byTask) {
|
|
278
|
+
if (!byTask.has(taskId)) byTask.set(taskId, paid);
|
|
279
|
+
}
|
|
254
280
|
out.set(id, {
|
|
255
281
|
credits,
|
|
256
282
|
paid: acc.total,
|
|
@@ -258,6 +284,7 @@ async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {})
|
|
|
258
284
|
payments: acc.payments + priced.estimatedPayments,
|
|
259
285
|
scale: compressIdeaSize(credits),
|
|
260
286
|
historical: priced.estimated > 0,
|
|
287
|
+
byTask,
|
|
261
288
|
});
|
|
262
289
|
}
|
|
263
290
|
return out;
|
|
@@ -367,7 +394,7 @@ async function walkDescentForIdeas(ids, { client }) {
|
|
|
367
394
|
// shape a single-idea panel wants.
|
|
368
395
|
async function ideaSize(ideaId, opts = {}) {
|
|
369
396
|
const key = idOf(ideaId);
|
|
370
|
-
const zero = { credits: 0, payments: 0, scale: 0 };
|
|
397
|
+
const zero = { credits: 0, payments: 0, scale: 0, byTask: new Map() };
|
|
371
398
|
if (!key) return zero;
|
|
372
399
|
const sizes = await ideaSizes([key], opts);
|
|
373
400
|
return sizes.get(key) || zero;
|