@bongos/core 1.20.17 → 1.20.19

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,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.20.17",
6
- "core_contract": "1.20.17",
7
- "source_commit": "4e92fa5764cf7041b99baefbf8d249edbba9159d",
5
+ "core_version": "1.20.19",
6
+ "core_contract": "1.20.19",
7
+ "source_commit": "634240626e5878bfa71f84a126f2a5066315c631",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-30T13:31:15.089Z",
9
+ "built_at": "2026-09-30T14:16:31.957Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 556,
12
+ "docs_redacted": 557,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2628,
14
+ "functional_verbatim": 2631,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3210,
20
- "tree_sha256": "4715c740ddb7d2c5cdfed3960679731b4a2cdef5b901f92945ba362f12f26570",
19
+ "file_count": 3214,
20
+ "tree_sha256": "1941a2dc44fb870a71c3227166d032c854f48237c2fef4cc958e355f9658f585",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -1537,7 +1537,7 @@
1537
1537
  {
1538
1538
  "path": "docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md",
1539
1539
  "mode": "0000644",
1540
- "sha256": "fd6c8d2e3868f2ba42262406b9fbd9ba12cb547a9eac61ddb7b9422afde6b3ab"
1540
+ "sha256": "2e00d10ae548548bc8708058b0beb08fc0631aa29f27f6905ef630fdf4c6099b"
1541
1541
  },
1542
1542
  {
1543
1543
  "path": "docs/adr/0214-canonical-profile-url-one-resolver-dark-until-the-page-exists.md",
@@ -2229,10 +2229,15 @@
2229
2229
  "mode": "0000644",
2230
2230
  "sha256": "4ca4eae961c994cae0dd9d306ea0a5a6f9ce660de161a62c5ccd125d910f70ec"
2231
2231
  },
2232
+ {
2233
+ "path": "docs/adr/0352-a-rollup-report-carries-its-as-of-in-a-header.md",
2234
+ "mode": "0000644",
2235
+ "sha256": "d74b4f6262d4f23fd67f73771bbe8187e9cb92fa321b83ffb33ef03cf96d1236"
2236
+ },
2232
2237
  {
2233
2238
  "path": "docs/adr/README.md",
2234
2239
  "mode": "0000644",
2235
- "sha256": "bcfa8004aeaebbfb6c32c53042665d7da49098e80dd858a3f353420f8ef2c523"
2240
+ "sha256": "943ac0f8c9c0b2dc5ef2a05241b0bac329c5fa986b09425841e12e62f4f9b936"
2236
2241
  },
2237
2242
  {
2238
2243
  "path": "docs/api-reference.md",
@@ -2242,7 +2247,7 @@
2242
2247
  {
2243
2248
  "path": "docs/api/openapi.json",
2244
2249
  "mode": "0000644",
2245
- "sha256": "829ba538e06cca081f0540bf819f280ba36e738a553e5fa3634af5f5af5bc5b0"
2250
+ "sha256": "796e47e332b7788981b5118aabfbacdc9b728e22796da301cedce44ac1ab8dc6"
2246
2251
  },
2247
2252
  {
2248
2253
  "path": "docs/architecture.md",
@@ -2267,12 +2272,12 @@
2267
2272
  {
2268
2273
  "path": "docs/copy-inventory.md",
2269
2274
  "mode": "0000644",
2270
- "sha256": "f4485fd6eecabff171add19c577d0c94b90b1e5e4e90e71f0af23bbd84085791"
2275
+ "sha256": "08ef6d32a1c5cfea14e7ddd54a4e7ab2e34818199a45e0422a4fce31b72567a7"
2271
2276
  },
2272
2277
  {
2273
2278
  "path": "docs/copy-registry.json",
2274
2279
  "mode": "0000644",
2275
- "sha256": "f8b6e13542de4d3fe03cbfc6a4873c8a7fd7c3ea29e9b535d80c70d2d986340a"
2280
+ "sha256": "a405d02a6308a53e9322437f21daf86cb7e7f0d6d8b15a25639b7fdbcb7c2fd1"
2276
2281
  },
2277
2282
  {
2278
2283
  "path": "docs/design/apex-pass-2-direction.md",
@@ -2772,7 +2777,7 @@
2772
2777
  {
2773
2778
  "path": "docs/module-api-changelog.md",
2774
2779
  "mode": "0000644",
2775
- "sha256": "c2490d5cdc3d8044ad02ba2ade0a9918e8c3d041e79f0ac0d61f3126152053d1"
2780
+ "sha256": "f7fe6c9e2a06b6dfb71087791b38b22ff4ce57f897320ade88e65a131fdb4160"
2776
2781
  },
2777
2782
  {
2778
2783
  "path": "docs/modules-contract.md",
@@ -2862,7 +2867,7 @@
2862
2867
  {
2863
2868
  "path": "docs/page-readings.json",
2864
2869
  "mode": "0000644",
2865
- "sha256": "b959e7b3c6b623f81e4f5752fa6573621c79d6e8948d18b2b4c939101e65930b"
2870
+ "sha256": "6ba44419e75dcb245e386e077eba647e744c56861b8b81c9002bbe7528369f0c"
2866
2871
  },
2867
2872
  {
2868
2873
  "path": "docs/project-context.template.md",
@@ -7009,6 +7014,11 @@
7009
7014
  "mode": "0000644",
7010
7015
  "sha256": "72be4f7e3aa3afa47dd78de7a57dcdf57b877675b44fbdf02f6607a8632e1787"
7011
7016
  },
7017
+ {
7018
+ "path": "modules/platform-identity/migrations/platform_identity_026_activity_reported_as_of.sql",
7019
+ "mode": "0000644",
7020
+ "sha256": "9f8d582d626dec82b55b96272f9d6d7f6c1488ac28bd9a98ab673404afde0c8e"
7021
+ },
7012
7022
  {
7013
7023
  "path": "modules/platform-identity/module.json",
7014
7024
  "mode": "0000644",
@@ -7022,7 +7032,7 @@
7022
7032
  {
7023
7033
  "path": "modules/platform-identity/platform-identity.js",
7024
7034
  "mode": "0000644",
7025
- "sha256": "badb8e19541a08a35f85037bc9f5130c553403ae75017423354d255f972cbe46"
7035
+ "sha256": "a823bd5e79795f93aa011470088b5840bf2ada6fb696fbf6b9b1c6c2ea85e5ef"
7026
7036
  },
7027
7037
  {
7028
7038
  "path": "modules/platform-identity/pollers/code-sweep.js",
@@ -7117,7 +7127,7 @@
7117
7127
  {
7118
7128
  "path": "modules/platform-identity/routes/sso.js",
7119
7129
  "mode": "0000644",
7120
- "sha256": "aa1bceddd27140e33d12f1c22ca362ba35352d837b556422b54633fd4fcedfc0"
7130
+ "sha256": "2906d873fa46a2745fbbecefdeb81194f6f433e97deed65ee5fbee3e9a7fa140"
7121
7131
  },
7122
7132
  {
7123
7133
  "path": "modules/platform-identity/scouting-authz.js",
@@ -7142,7 +7152,7 @@
7142
7152
  {
7143
7153
  "path": "modules/platform-identity/tests/platform-identity.mjs",
7144
7154
  "mode": "0000644",
7145
- "sha256": "788915622457990f61b7b95cc6266d1e285097100100be06419145702191c9ce"
7155
+ "sha256": "93d74c10d601eab840da83b5d33d7bbf51907830626401cc27fadb138441fcc3"
7146
7156
  },
7147
7157
  {
7148
7158
  "path": "modules/platform-identity/ui/.gitkeep",
@@ -8032,7 +8042,7 @@
8032
8042
  {
8033
8043
  "path": "modules/public-landing/public/terms.html",
8034
8044
  "mode": "0000644",
8035
- "sha256": "639d96a2bf6b28fabbcad64a01e3b97aab83dabe8182455074f2845e053b2696"
8045
+ "sha256": "39aae17cd14c496533f9a212f37378571d9d621c078353e41900a02c5355df5f"
8036
8046
  },
8037
8047
  {
8038
8048
  "path": "modules/render-deploy/deploys.js",
@@ -8862,12 +8872,12 @@
8862
8872
  {
8863
8873
  "path": "package-lock.json",
8864
8874
  "mode": "0000644",
8865
- "sha256": "f04a559437cf80ab6eeb46905a8f943091dd4601e02b7d7ae58ca57aae35b050"
8875
+ "sha256": "01b48ab609eb6e0b51a3420a79cd5e13158e3bf3f996a5bd30dbd1c2927c5b91"
8866
8876
  },
8867
8877
  {
8868
8878
  "path": "package.json",
8869
8879
  "mode": "0000644",
8870
- "sha256": "b8cd817f9d22b1222c06404648c197080fce17573ce0c83f43b4f03f87fb06b4"
8880
+ "sha256": "15c8438b1ea0a683e536e21ca2ed353042533ddf893b727c3835d5422595797a"
8871
8881
  },
8872
8882
  {
8873
8883
  "path": "public-docs/index.html",
@@ -8887,7 +8897,7 @@
8887
8897
  {
8888
8898
  "path": "release-notes.json",
8889
8899
  "mode": "0000644",
8890
- "sha256": "06f6c8587835146b2f325adf518aca1e74699cb2f38f0313ad4bf7f5fe37577c"
8900
+ "sha256": "426ac20e29712617fe5881fdc428ff184ab09e2e0cfc093a99b1f10c192291a3"
8891
8901
  },
8892
8902
  {
8893
8903
  "path": "scripts/bongos-mcp.js",
@@ -10047,7 +10057,7 @@
10047
10057
  {
10048
10058
  "path": "scripts/gds/run-unit-tests.js",
10049
10059
  "mode": "0000644",
10050
- "sha256": "55ffec8533dc352849c2a9bb40663a1a0c95d72d466360bf47091e9491683b0b"
10060
+ "sha256": "456b185fcbab4fb486fb15aecf0de260183c9eda9942c12c0123000a8baeaa57"
10051
10061
  },
10052
10062
  {
10053
10063
  "path": "scripts/gds/runner-drift.js",
@@ -10667,7 +10677,7 @@
10667
10677
  {
10668
10678
  "path": "src/bongos/auth-admission.js",
10669
10679
  "mode": "0000644",
10670
- "sha256": "2d11e7b745a0476fa0643fd997c2e17a49709bae1ef93a7c4636f7efd18c7ab0"
10680
+ "sha256": "9fd9311df751a418985073db3aed97825e52d2a45ca7501b93f93cf5019c1fe6"
10671
10681
  },
10672
10682
  {
10673
10683
  "path": "src/bongos/auth-config.js",
@@ -11002,7 +11012,7 @@
11002
11012
  {
11003
11013
  "path": "src/module-api.js",
11004
11014
  "mode": "0000644",
11005
- "sha256": "ae8085fdd1b2e41ff442de4a863b7b105bebaad915054a0cfa38bb57321289be"
11015
+ "sha256": "f80e044dd532aabc41f3731129d98f63c98c2d60a9a4fc3eecbf713e3a86650e"
11006
11016
  },
11007
11017
  {
11008
11018
  "path": "src/module-loader/catalog.js",
@@ -11104,10 +11114,20 @@
11104
11114
  "mode": "0000644",
11105
11115
  "sha256": "f7434d510d656e5081f735d1f9356ecb11e8c3630a86a86137c3290134541c8d"
11106
11116
  },
11117
+ {
11118
+ "path": "tests/activity_rollup_as_of.mjs",
11119
+ "mode": "0000644",
11120
+ "sha256": "d5903404f0fed48e599b68f259e0d79a44d11c249bb69bc4b59643e7dac62cbb"
11121
+ },
11122
+ {
11123
+ "path": "tests/activity_rollup_order_db.mjs",
11124
+ "mode": "0000644",
11125
+ "sha256": "497b453aa441ba06276d5146fc71f54da111a12c7c0c2e192da199cc1f2812ec"
11126
+ },
11107
11127
  {
11108
11128
  "path": "tests/activity_snapshots_db.mjs",
11109
11129
  "mode": "0000644",
11110
- "sha256": "219569f3160eb22a89e92a217b854a194afba6ebc7032dc07eff2534efc4390a"
11130
+ "sha256": "73cb5a43e3f3ce61dd7f57720e523a310390ed60ed491d39fdaf8b8e9c78053a"
11111
11131
  },
11112
11132
  {
11113
11133
  "path": "tests/admission_ceiling_gate.mjs",
@@ -14347,7 +14367,7 @@
14347
14367
  {
14348
14368
  "path": "tests/profile_rollup_consent.mjs",
14349
14369
  "mode": "0000644",
14350
- "sha256": "986cd6a9aa83743f5d2bc6083803e6ed576f6b7b35a8c6dc05e9096fa022f898"
14370
+ "sha256": "093f078f965454e535d6026c1852a0e0668695809f270f84e5d26dba4025abbd"
14351
14371
  },
14352
14372
  {
14353
14373
  "path": "tests/profile_route.mjs",
@@ -15757,7 +15777,7 @@
15757
15777
  {
15758
15778
  "path": "tests/terms_acceptance.mjs",
15759
15779
  "mode": "0000644",
15760
- "sha256": "d73eea1d83bdf132f5973a67013bd01270b54253ef6eb979c801b019d6f67c6f"
15780
+ "sha256": "80ea17a764ba05a05af0967e9951370b814c20c3518a37f1c3ca1a9ca0d044cf"
15761
15781
  },
15762
15782
  {
15763
15783
  "path": "tests/terms_grandfather_db.mjs",
@@ -29,6 +29,8 @@ This is deliberately the ADR 0205 D3 property, reused. An acceptance record a ca
29
29
 
30
30
  > **Amended 2026-09-25 ([task 1003353](https://cloudbongos.com/builders#/task/1003353); [blocker 1000124](https://cloudbongos.com/builders#/blocker/1000124)) — what "the hub's own sign-in" means.** `GET /sso/authorize` is reached when a builder uses their platform account to sign in to a **project**; the only builder of that URL is a child instance's `idpAuthorizeUrl`. Signing in to the hub itself creates a hub session and never touches `platform_identity_accounts`, so it records no acceptance. `/terms` said "signed in here"; it now names a project sign-in. The owner chose to also capture the hub's own sign-in ([task 1003361](https://cloudbongos.com/builders#/task/1003361)), which will make this a second capture point rather than a wording fix. Two smaller corrections to the text above: the device poll does not go through `ensureAccount`, it is a non-activating `upsertAccount`, and so is `PATCH /me/handle`; `ensureAccount` is the federated membership check-in alone. The outcome was always the same (neither writes an acceptance), and both paths are now executed in `tests/terms_acceptance.mjs` rather than asserted as text.
31
31
 
32
+ > **Amended 2026-09-30 ([task 1004265](https://cloudbongos.com/builders#/task/1004265)) — a project sign-in *in the browser*.** The CLI's project sign-in (`POST /sso/device/poll`) also uses the platform account, and it stays **non-activating on purpose**. The person approves it on GitHub's device page, never on a hub page, so these terms are never in front of them — and the same branch that stamps the acceptance also flips `profile_state` to active, which ADR 0171 D3 reserves for the one path "where a human is demonstrably at the hub" — and D3 names the device poll as `provisional` in so many words. Stamping it would record consent nobody was shown and re-decide D3 by implementation convenience. `/terms` therefore names a project sign-in **in your browser**, and says that a command-line sign-in records nothing and why, so a CLI-only builder is not left guessing why they are unpublished. `tests/terms_acceptance.mjs` §3 drives the real device-poll route (no acceptance, still provisional, the non-activating literal, a forged body refused before any write) and a later browser sign-in stamping that same account. The "next hub sign-in" wording under D4 is left for task 1003361, which makes it true.
33
+
32
34
  ### D2 — Written once. The first acceptance is the one that stands
33
35
 
34
36
  The conflict branch writes `COALESCE(platform_identity_accounts.terms_accepted_at, now())`. Signing in again re-reads the terms but does not restamp them, so the column keeps meaning *when consent was given* rather than *when this account was last seen*. `terms_version` is COALESCE'd the same way and always written in the same statement, so the pair cannot diverge.
@@ -0,0 +1,38 @@
1
+ # ADR 0352 — A rollup report carries its "as of" in a header, and the hub keeps the newest
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-30
5
+ - **Task:** [task 1004268](https://cloudbongos.com/builders#/task/1004268) (goal 1000110), found in [task 1004244](https://cloudbongos.com/builders#/task/1004244)'s code review
6
+ - **Interacts with:** [ADR 0141](<redacted>.md) §4 (the activity rollup), [ADR 0171](0171-profile-rollup-join-and-consent.md) D7 (a report is third-party self-report), [ADR 0083](0083-modular-architecture-strangler-build-plan.md) §5 (module-namespaced migrations)
7
+
8
+ ## Context
9
+
10
+ `POST /sso/activity/rollup` upserted the live activity row last-writer-wins, and a report carried no timestamp. An instance pushes one on every federated sign-in and one on every ship, so a sign-in report read earlier and a ship report read later can reach the hub together, and the older one can commit last. The live row and, since task 1004244, the newest snapshot then show the older totals. The leaderboard reads a regression that never happened until the builder's next report. It is low-urgency, since the next report corrects it, but it is wrong in exactly the place people compare numbers.
11
+
12
+ The fix the task names is for the instance to send when it read the totals, and for the hub to refuse an older report. What is not obvious is where that stamp travels.
13
+
14
+ ## Decision
15
+
16
+ **D1 — The stamp is a request header, `Bongos-Report-As-Of`, not a body field.** The hub validates the rollup body strictly (`validateOrRespond`), so an unknown field is a 400. The hub and its instances upgrade independently, and an instance can run a newer core than the hub (today the hub's pin trails main). A body field sent by an upgraded instance would be refused by a hub that predates it. The sender never reads the response, so every report from that instance would be lost silently until the hub caught up. An unknown header is ignored, so the header is safe in both directions: an old hub ignores it, and a new hub treats its absence as "unstamped".
17
+
18
+ **D2 — Unstamped means the old behaviour, never a refusal.** A missing or unparseable header binds NULL. A NULL on either side of the comparison (an older instance's report, or a row written before migration 026) applies last-writer-wins, as before. An unstamped report also clears the stored stamp, so a stamp never outlives the numbers it described. A malformed header is not a 400, for D1's reason: a refusal drops the report, when the only thing lost should be its ordering.
19
+
20
+ **D3 — The guard is a WHERE on the upsert's conflict branch.** `ON CONFLICT (client_id, github_id) DO UPDATE … WHERE EXCLUDED.reported_as_of IS NULL OR <row>.reported_as_of IS NULL OR EXCLUDED.reported_as_of >= <row>.reported_as_of`. A skipped update still takes the row lock, so racing reports stay queued exactly as task 1004244 arranged, and the snapshot beside it copies an unchanged live row, which records nothing. Ties apply (the later arrival wins, as before).
21
+
22
+ **D4 — The stamp is the instance's clock, compared only with itself, and clamped to the hub's.** A row's stored stamp only ever comes from the same client for the same builder, so skew between an instance and the hub does not matter. A stamp later than the hub's `now()` is stored as `now()`, so an instance whose clock runs ahead cannot freeze the row until its clock is reached. The sender stamps before its first read, so a report is never ordered ahead of totals read after it.
23
+
24
+ ## Consequences
25
+
26
+ - Migration `platform_identity_026_activity_reported_as_of.sql` adds one nullable column, with no backfill and no default. An invented stamp on an existing row could refuse an honest report from an instance whose clock reads earlier than the hub's.
27
+ - A mixed fleet behaves as before until each instance upgrades. An upgraded instance talking to an old hub is also unaffected.
28
+ - **Honest limit:** an instance clock that steps backwards (an NTP correction after running fast) has its reports refused until it passes the last stamp it sent. The skew bounds this, the clamp bounds the skew by the hub's own time, and the next report after that applies. A report refused this way still answers `{ ok: true }`, because the instance ignores the body.
29
+ - **The pattern generalises:** a field added to a hub↔instance wire that the older side validates strictly must be able to ride where the older side ignores it, or the change must land on the hub first and be released before any instance sends it.
30
+
31
+ Proof: `tests/activity_rollup_order_db.mjs` (real Postgres: the out-of-order race, ties, NULLs on each side, the future clamp, and ten rounds of five racing writers ending on the latest stamp with the snapshot agreeing) and `tests/activity_rollup_as_of.mjs` (DB-free: the sender stamps a header before its reads and never the body; the route binds it and degrades a bad one to unstamped).
32
+
33
+ ## Alternatives considered
34
+
35
+ - **A body field `as_of`.** This is what the task sketched. It is lost against any older hub (D1).
36
+ - **`GREATEST` on each total.** Credits and karma can legitimately fall, so this would pin a clawback forever.
37
+ - **The standard `Date` header.** It has one-second granularity, and a sign-in push and a ship push inside the same second are exactly the case being fixed.
38
+ - **A 400 on a malformed stamp.** It turns a sender bug into silent data loss (D2).
@@ -470,3 +470,4 @@ These 20 numbers are each shared by exactly two files. They are **accepted histo
470
470
  | 0349 | [**A version preview is a sandboxed child the web tier launches, reached by a cookie** ([task 1004300](https://cloudbongos.com/builders#/task/1004300), goal 1000090 — BONGOS-V2, owner Lars, approved 2026-09-29). Amends ADR 0293 D1 for this feature only. **D1 (owner):** route by a `bongos_preview` cookie through an optional `request.divert` seam, not a path; forward only with the cookie, off the sign-in and preview paths, with no Bearer, and with `core.pin.move` re-checked on every request. **D2 (owner):** the npm-release module launches the preview itself, with a deny-by-default environment, a dump-and-restore copy of the database (never a template copy), one at a time, 20-minute idle stop, published versions within 30 of the running one. **D3:** the same database role and no egress firewall are accepted for now.](0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md) | modules / npm-release / deploy |
471
471
  | 0350 | [**The hub holds a per-project WRITE deploy key, so a hosted project's upgraded pin always reaches its GitHub repo** ([task 1004291](https://cloudbongos.com/builders#/task/1004291), follow-up to task 1004065). Amends ADR 0176. The owner's token lasts one hour and the checkout's own key is read-only, so most hosted upgrades ended "GitHub was not updated". **D1:** a second deploy key per repo, registered `read_only: false` as `cloudbongos-pin-<slug>`, the pull key untouched. **D2:** kept outside every checkout in the runner's own directory (0700 dir, 0600 key, modes verified), unreadable by the project's account. **D3:** minted at standup and whenever a fresh owner token finds none, so reconnecting GitHub once is enough. **D4:** `pushUpgradePin` tries write key, then owner token, then origin, never forced. **D5:** revoked on teardown and disconnect. **D6:** a project on the shared app user is refused a key: "needs its own account first". **D7:** /deploy says reconnecting sets up lasting access.](0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md) | provisioning / security |
472
472
  | 0351 | [**A criterion closes on a UAT, not on its linked tasks shipping** ([task 1004392](https://cloudbongos.com/builders#/task/1004392), goal 1000089 — working area 3). Supersedes ADR 0183's close rule. `wa7-government` had closed on six Board Room navigation tasks while nothing it describes was on the live site. **D1:** three checks — Code (linked tasks done, ≥1 shipped; automatic), Live, UAT — and the sweep closes a criterion only with a CURRENT sign-off (no older than the newest linked ship). **D2 (owner):** "Awaiting UAT" is a visible state. **D3 (owner):** one step; the signer is refused if they shipped linked work, the project owner excepted. **D4 (owner):** an mp4/webm up to 100 MB on the project's own server. **D5 (owner):** backend-only is set while a criterion is open and takes a recording-free sign-off. **D6 (owner):** Live is read via the `deploy.taskWhere` port where the site can tell, attested otherwise. **D7 (owner):** already-closed criteria stay closed ("Closed before UAT"). **D8:** `/satisfy` is a recorded override that needs a reason. **D9:** `/goal-uat` + `scripts/gds/uat.js`; the hall is task 1004402.](0351-a-criterion-closes-on-a-uat.md) | lifecycle / criteria / review |
473
+ | 0352 | [**A rollup report carries its "as of" in a header, and the hub keeps the newest** ([task 1004268](https://cloudbongos.com/builders#/task/1004268), goal 1000110). `POST /sso/activity/rollup` was last-writer-wins with no report timestamp, so a sign-in push read earlier and a ship push read later could land the older totals last. **D1:** the instance's read time rides a `Bongos-Report-As-Of` HEADER, not a body field, because the hub validates the body strictly and hub and instances upgrade independently: a body field from a newer instance would 400 on an older hub and lose the report, while an unknown header is ignored. **D2:** missing or unparseable = unstamped = last-writer-wins, never a 400. **D3:** the guard is a WHERE on the upsert's conflict branch (migration 026, `reported_as_of`, no backfill); a skipped update keeps the row lock, so the task 1004244 snapshot stays in step. **D4:** the stamp is compared only with the same client's, and clamped to the hub's `now()`.](0352-a-rollup-report-carries-its-as-of-in-a-header.md) | platform identity / federation |
@@ -18502,7 +18502,7 @@
18502
18502
  "sso"
18503
18503
  ],
18504
18504
  "summary": "POST /sso/activity/rollup",
18505
- "description": "POST /sso/activity/rollup — a federated instance reports a builder's activity snapshot (ADR 0141 §4). Server-to-server, client_id+client_secret auth. Keyed to the AUTHENTICATED client_id. Descriptive analytics for the Archon-only scouting view — never authority. rank: public — authenticated by client_id + client_secret, not a builder rank.\n\n**Rank:** `public` — No authentication — any caller.",
18505
+ "description": "POST /sso/activity/rollup — a federated instance reports a builder's activity snapshot (ADR 0141 §4). Server-to-server, client_id+client_secret auth. Keyed to the AUTHENTICATED client_id. Descriptive analytics for the Archon-only scouting view — never authority. Optional `Bongos-Report-As-Of` header (task 1004268): when the instance read these totals, so an older report landing last cannot overwrite a newer one. A HEADER, not a body field, because the body is validated strictly and the hub and its instances upgrade independently: a new instance's body field would 400 on an older hub and the report would be lost, while an unknown header is ignored. Absent or unparseable reads as unstamped (last-writer-wins, as before) rather than a 400 — the sender never reads the response, so a refusal would silently drop the report instead of only its ordering. rank: public — authenticated by client_id + client_secret, not a builder rank.\n\n**Rank:** `public` — No authentication — any caller.",
18506
18506
  "x-rank": "public",
18507
18507
  "x-source": "modules/platform-identity/routes/sso.js",
18508
18508
  "requestBody": {
@@ -4,7 +4,7 @@
4
4
  > Deterministic on a given tree — regenerate and commit rather than hand-editing.
5
5
  > Machine-readable twin: [`copy-registry.json`](copy-registry.json).
6
6
 
7
- **2189 strings** across 3 surfaces · 1889 distinct pieces of copy · 2189 certain, 0 heuristic · 207 shown in more than one place.
7
+ **2188 strings** across 3 surfaces · 1888 distinct pieces of copy · 2188 certain, 0 heuristic · 207 shown in more than one place.
8
8
 
9
9
  ## Surfaces
10
10
 
@@ -12,7 +12,7 @@
12
12
  |---|---|---|---|
13
13
  | `builders-hall` | `modules/hall-ui/public` | 109 | 1617 |
14
14
  | `status-dashboard` | `modules/status-ui/public` | 4 | 100 |
15
- | `landing` | `modules/public-landing/public` | 6 | 472 |
15
+ | `landing` | `modules/public-landing/public` | 6 | 471 |
16
16
 
17
17
  ## Confidence, and why it is on every row
18
18
 
@@ -1805,7 +1805,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1805
1805
  | `7667eb552781` | certain | {…}: {…}% ({…}/{…} checks) | `modules/status-ui/public/status.js:468` |
1806
1806
  | `6d0d64f10bd9` | certain | ‹ earlier page | `modules/status-ui/public/index.html:321` |
1807
1807
 
1808
- ## `landing` — 472 strings
1808
+ ## `landing` — 471 strings
1809
1809
 
1810
1810
  | id | conf | text | where |
1811
1811
  |---|---|---|---|
@@ -1940,7 +1940,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1940
1940
  | `485c565b5745` | certain | How people join | `modules/public-landing/public/projects.html:910` |
1941
1941
  | `4b542b000afa` | certain | How this project is drawn: on the sky, in your list, at the top of this page and on its card. A planet is a | `modules/public-landing/public/projects.html:1030` |
1942
1942
  | `9f31eaed8252` | certain | If you grant repository access while creating a project, that permission is used to create or adopt your repos | `modules/public-landing/public/privacy.html:55` |
1943
- | `3b1cbe9e33aa` | certain | If your account already existed when this version took effect, the platform accepted it on your behalf, rather | `modules/public-landing/public/terms.html:71` |
1943
+ | `d37e2fbde8c6` | certain | If your account already existed when this version took effect, the platform accepted it on your behalf, rather | `modules/public-landing/public/terms.html:71` |
1944
1944
  | `607551ae732b` | certain | Invite a builder | `modules/public-landing/public/projects.html:1003` |
1945
1945
  | `20786b02f47a` | certain | Invite builders | `modules/public-landing/public/projects.html:1492` |
1946
1946
  | `a1256030d879` | certain | Invite only | `modules/public-landing/public/projects.html:914` |
@@ -1979,7 +1979,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1979
1979
  | `837fdaa0936a` | certain | Nothing matches that. | `modules/public-landing/public/projects.html:707` |
1980
1980
  | `1bc11853e893` | certain | On the map | `modules/public-landing/public/projects.html:929` |
1981
1981
  | `b4ac8f44710c` | certain | On the public map | `modules/public-landing/public/projects.html:851` |
1982
- | `7fe31ce20c09` | certain | Once you have signed in to a project with your platform account and accepted these terms, your profile and tha | `modules/public-landing/public/terms.html:68` |
1982
+ | `1fdda2903cf9` | certain | Once you have signed in to a project in your browser with your platform account and accepted these terms, your | `modules/public-landing/public/terms.html:68` |
1983
1983
  | `8329bb1a3693` | certain | One tap files the request and the platform starts standing your project up — server, database, address. Here's | `modules/public-landing/public/projects.html:1462` |
1984
1984
  | `86cde1930d84` | certain | Only mine | `modules/public-landing/public/projects.html:477` |
1985
1985
  | `f82fe1982ab3` | certain | Open an issue on the repository | `modules/public-landing/public/contact.html:52` |
@@ -2092,7 +2092,6 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
2092
2092
  | `fb416bf1a26b` | certain | The handle names your project on the platform — and its address comes with it. Once it stands up, a few GitHub | `modules/public-landing/public/projects.html:1355` |
2093
2093
  | `a954f0dce9a3` | certain | The platform | `modules/public-landing/public/contact.html:65` |
2094
2094
  | `4fd8f5746cc5` | certain | The platform is run as a non-profit, capture-resistant commons. | `modules/public-landing/public/terms.html:50` |
2095
- | `dede132846fe` | certain | The platform stores the date you accepted these terms, and which version, on your account. It is written when | `modules/public-landing/public/terms.html:70` |
2096
2095
  | `20fcebaa85c4` | certain | The project hub needs JavaScript — the map, your projects, and starting a new one all live here. You can read | `modules/public-landing/public/projects.html:530` |
2097
2096
  | `4a740b5afe73` | certain | The project map | `modules/public-landing/public/projects.html:540` |
2098
2097
  | `345fec12e5d8` | certain | The repository | `modules/public-landing/public/contact.html:50` |
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 2189,
7
- "unique_ids": 1889,
8
- "certain": 2189,
6
+ "strings": 2188,
7
+ "unique_ids": 1888,
8
+ "certain": 2188,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 207
11
11
  },
@@ -23,7 +23,7 @@
23
23
  "landing": {
24
24
  "at": "modules/public-landing/public",
25
25
  "files": 6,
26
- "strings": 472
26
+ "strings": 471
27
27
  }
28
28
  },
29
29
  "blind_spots": [
@@ -17936,9 +17936,9 @@
17936
17936
  "confidence": "certain"
17937
17937
  },
17938
17938
  {
17939
- "id": "3b1cbe9e33aa",
17939
+ "id": "d37e2fbde8c6",
17940
17940
  "surface": "landing",
17941
- "text": "If your account already existed when this version took effect, the platform accepted it on your behalf, rather than waiting for you to sign in, and dated it the day that was done. The record marks that acceptance as grandfathered, so it is never mistaken for one you gave, and your own acceptance replaces it the next time you sign in to a project.",
17941
+ "text": "If your account already existed when this version took effect, the platform accepted it on your behalf, rather than waiting for you to sign in, and dated it the day that was done. The record marks that acceptance as grandfathered, so it is never mistaken for one you gave, and your own acceptance replaces it the next time you sign in to a project in your browser.",
17942
17942
  "file": "modules/public-landing/public/terms.html",
17943
17943
  "line": 71,
17944
17944
  "origin": "html-text",
@@ -18287,9 +18287,9 @@
18287
18287
  "confidence": "certain"
18288
18288
  },
18289
18289
  {
18290
- "id": "7fe31ce20c09",
18290
+ "id": "1fdda2903cf9",
18291
18291
  "surface": "landing",
18292
- "text": "Once you have signed in to a project with your platform account and accepted these terms, your profile and that record are public by default: at your handle&rsquo;s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in to a project that way stays provisional and is published nowhere.",
18292
+ "text": "Once you have signed in to a project in your browser with your platform account and accepted these terms, your profile and that record are public by default: at your handle&rsquo;s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in to a project that way stays provisional and is published nowhere.",
18293
18293
  "file": "modules/public-landing/public/terms.html",
18294
18294
  "line": 68,
18295
18295
  "origin": "html-text",
@@ -19303,15 +19303,6 @@
19303
19303
  "origin": "html-text",
19304
19304
  "confidence": "certain"
19305
19305
  },
19306
- {
19307
- "id": "dede132846fe",
19308
- "surface": "landing",
19309
- "text": "The platform stores the date you accepted these terms, and which version, on your account. It is written when you sign in to a project with your platform account, and only once &mdash; signing in again does not change the date you accepted. This is version",
19310
- "file": "modules/public-landing/public/terms.html",
19311
- "line": 70,
19312
- "origin": "html-text",
19313
- "confidence": "certain"
19314
- },
19315
19306
  {
19316
19307
  "id": "20fcebaa85c4",
19317
19308
  "surface": "landing",
@@ -2691,5 +2691,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2691
2691
  landed since 1.20.15 with no explicit bump. run 36717488992. (task 1002620)
2692
2692
  1.20.17 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2693
2693
  landed since 1.20.16 with no explicit bump. run 36722139698. (task 1002620)
2694
+ 1.20.18 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2695
+ landed since 1.20.17 with no explicit bump. run 36724081934. (task 1002620)
2696
+ 1.20.19 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2697
+ landed since 1.20.18 with no explicit bump. run 36727774141. (task 1002620)
2694
2698
  ---------------------------------------------------------------------------
2695
2699
  ```
@@ -5,7 +5,7 @@
5
5
  "adr": "0341 D3",
6
6
  "reread": "node scripts/gds/page-reader.js --stale",
7
7
  "how": ["Generated by node scripts/gds/page-reader.js (BV2.TW04, task 1004315; ADR 0341 D3). Do not hand-edit.","One entry per page of docs/page-inventory.json. Lines are in page order: the default state first, and a line only another state shows sits where that state shows it, with its state named.","placement: placed = a docs/copy-registry.json row renders it (file, line, string_id); shared = shell text every page of the surface carries (top bar, sidebar, footer), with its row when the registry has one; unplaced = the registry cannot place it (a rewrite becomes an engineer task, ADR 0341 D4).","text keeps {…} where the registry row has a hole, or where a value the page read from its API was filled in. A string that is only such data is counted under omitted.data, not listed.","reading_hash is over the non-shared lines (key, section, text); files_hash is over the page's inventory files. --check compares files_hash only and never opens a browser.","Rendered through the hall-preview harness (hall) and the ui-design kit stub (landing, status), at 1280x800 with the clock pinned, so a reading is the fixture world's page."],
8
- "counts": {"pages":39,"lines":3791,"placed":1278,"shared":254,"unplaced":2259,"omitted_data":2054},
8
+ "counts": {"pages":39,"lines":3791,"placed":1277,"shared":254,"unplaced":2260,"omitted_data":2054},
9
9
  "pages": [
10
10
  {
11
11
  "id": "builders:atlas",
@@ -4132,9 +4132,9 @@
4132
4132
  "surface": "landing",
4133
4133
  "title": "{{productName}} — Terms",
4134
4134
  "states_read": ["default"],
4135
- "files_hash": "1523219e46cd7488",
4136
- "reading_hash": "a68fd4a222091122",
4137
- "counts": {"lines":45,"placed":29,"shared":9,"unplaced":7},
4135
+ "files_hash": "5f1171d0ce6fb7d3",
4136
+ "reading_hash": "007bea800099720b",
4137
+ "counts": {"lines":45,"placed":28,"shared":9,"unplaced":8},
4138
4138
  "omitted": {"data":1},
4139
4139
  "notes": [],
4140
4140
  "lines": [
@@ -4158,13 +4158,13 @@
4158
4158
  {"key":"L0018","section":"Your work across projects","text":"These numbers describe; they do not govern.","placement":"placed","file":"modules/public-landing/public/terms.html","line":67,"string_id":"02e596cc4569"},
4159
4159
  {"key":"L0019","section":"Your work across projects","text":"Your cross-project record is descriptive analytics and never authority. What you may do inside any project is decided by that project alone, from its own records, at the moment you ask. A large record grants nothing and a hidden one takes nothing away; no project is required to read it, and {…} may treat it as permission. A rank shown here is that project’s own standing for you, as that project reported it — the platform holds no rank of its own.","placement":"unplaced"},
4160
4160
  {"key":"L0020","section":"Your work across projects","text":"What is public.","placement":"placed","file":"modules/public-landing/public/terms.html","line":68,"string_id":"9b3312899fb1"},
4161
- {"key":"L0021","section":"Your work across projects","text":"Once you have signed in to a project with your platform account and accepted these terms, your profile and that record are public by default: at your handle’s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in to a project that way stays provisional and is published nowhere.","placement":"placed","file":"modules/public-landing/public/terms.html","line":68,"string_id":"7fe31ce20c09"},
4161
+ {"key":"L0021","section":"Your work across projects","text":"Once you have signed in to a project in your browser with your platform account and accepted these terms, your profile and that record are public by default: at your handle’s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in to a project that way stays provisional and is published nowhere.","placement":"placed","file":"modules/public-landing/public/terms.html","line":68,"string_id":"1fdda2903cf9"},
4162
4162
  {"key":"L0022","section":"Your work across projects","text":"What you control.","placement":"placed","file":"modules/public-landing/public/terms.html","line":69,"string_id":"6130dcb60532"},
4163
4163
  {"key":"L0023","section":"Your work across projects","text":"From your settings you can hide your statistics, drop out of the recruiting directory, or make your account private. Each takes effect immediately, everywhere, including on a page someone already has open. Hiding removes the platform’s combined view; it does not retract the per-project numbers each project publishes itself, so a determined reader could still add them up the hard way.","placement":"placed","file":"modules/public-landing/public/terms.html","line":69,"string_id":"0808df6e434c"},
4164
4164
  {"key":"L0024","section":"Your work across projects","text":"Your acceptance is recorded.","placement":"placed","file":"modules/public-landing/public/terms.html","line":70,"string_id":"0f06f7e72ea7"},
4165
- {"key":"L0025","section":"Your work across projects","text":"The platform stores the date you accepted these terms, and which version, on your account. It is written when you sign in to a project with your platform account, and only once — signing in again does not change the date you accepted. This is version","placement":"placed","file":"modules/public-landing/public/terms.html","line":70,"string_id":"dede132846fe"},
4165
+ {"key":"L0025","section":"Your work across projects","text":"The platform stores the date you accepted these terms, and which version, on your account. It is written when you sign in to a project in your browser with your platform account, and only once — signing in again does not change the date you accepted. Signing in from the command line does not record it: you approve that sign-in on GitHub’s page, where these terms are not shown, so an account that has only signed in that way stays unpublished until you sign in to a project in your browser once. This is version","placement":"unplaced"},
4166
4166
  {"key":"L0026","section":"Your work across projects","text":"Accounts from before these terms.","placement":"placed","file":"modules/public-landing/public/terms.html","line":71,"string_id":"9b02ec8a2e21"},
4167
- {"key":"L0027","section":"Your work across projects","text":"If your account already existed when this version took effect, the platform accepted it on your behalf, rather than waiting for you to sign in, and dated it the day that was done. The record marks that acceptance as grandfathered, so it is never mistaken for one you gave, and your own acceptance replaces it the next time you sign in to a project.","placement":"placed","file":"modules/public-landing/public/terms.html","line":71,"string_id":"3b1cbe9e33aa"},
4167
+ {"key":"L0027","section":"Your work across projects","text":"If your account already existed when this version took effect, the platform accepted it on your behalf, rather than waiting for you to sign in, and dated it the day that was done. The record marks that acceptance as grandfathered, so it is never mistaken for one you gave, and your own acceptance replaces it the next time you sign in to a project in your browser.","placement":"placed","file":"modules/public-landing/public/terms.html","line":71,"string_id":"d37e2fbde8c6"},
4168
4168
  {"key":"L0028","section":"Fair use","text":"Fair use","placement":"placed","file":"modules/public-landing/public/terms.html","line":73,"string_id":"2585087f4873"},
4169
4169
  {"key":"L0029","section":"Fair use","text":"Don't use {{productName}} to break the law, to attack this site or other people's projects, to mine, spam or otherwise abuse hosted resources, or to harass other builders. The owner may deactivate a builder or take a project offline for breaking these, and may set further house rules inside the hall.","placement":"placed","file":"modules/public-landing/public/terms.html","line":74,"string_id":"8d812912c392"},
4170
4170
  {"key":"L0030","section":"Liability","text":"Liability","placement":"unplaced"},
@@ -0,0 +1,27 @@
1
+ -- platform_identity_026_activity_reported_as_of.sql — WHEN the reporting instance
2
+ -- read the totals a rollup carries (task 1004268).
3
+ --
4
+ -- WHY. POST /sso/activity/rollup upserted the live activity row last-writer-wins,
5
+ -- and a report carried no timestamp. A sign-in report and a ship report racing to
6
+ -- the hub could commit in the wrong order, leaving the older totals live (and, since
7
+ -- task 1004244, as the newest snapshot) — a leaderboard regression that never
8
+ -- happened, until the builder's next report.
9
+ --
10
+ -- WHAT. One nullable column: the instance's own "as of" for the stored totals, sent
11
+ -- as the Bongos-Report-As-Of header and clamped to the hub's clock on write.
12
+ -- upsertActivity's conflict branch applies a report only when its stamp is at least
13
+ -- this one. NULL means "unstamped" — every row written before this column, and every
14
+ -- report from an instance too old to send the header — and an unstamped side always
15
+ -- applies, which is exactly the old behaviour.
16
+ --
17
+ -- No backfill, no default: inventing a stamp for an existing row would make it
18
+ -- refuse an honest report from an instance whose clock reads earlier than the hub's.
19
+ --
20
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent.
21
+
22
+ BEGIN;
23
+
24
+ ALTER TABLE platform_identity_builder_project_activity
25
+ ADD COLUMN IF NOT EXISTS reported_as_of timestamptz;
26
+
27
+ COMMIT;
@@ -664,8 +664,10 @@ function sanitizeRecentShips(raw, clientOrigin) {
664
664
  // Descriptive analytics — never authority. Refreshes last_active on each report.
665
665
  // `clientOrigin` is the AUTHENTICATED client's registered origin (never a
666
666
  // caller-named one) and exists only to bound the ship links — see
667
- // origin-containment.js:urlWithinOrigin.
668
- async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], clientOrigin = null }, { pool = defaultPool } = {}) {
667
+ // origin-containment.js:urlWithinOrigin. `asOf` is when the instance read these
668
+ // totals (task 1004268, migration 026): an older report never overwrites a newer
669
+ // one; unstamped (an older instance) keeps last-writer-wins; a future stamp is clamped.
670
+ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], clientOrigin = null, asOf = null }, { pool = defaultPool } = {}) {
669
671
  // api.LIVE_RANK_LADDER is a published doorway export (src/module-api.js, a
670
672
  // lazy getter over db-kernel's canonical four-rank ladder).
671
673
  const safeRank = api.LIVE_RANK_LADDER.includes(rank) ? rank : null;
@@ -677,17 +679,20 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
677
679
  return api.withTx(async (client) => {
678
680
  const { rows } = await client.query(
679
681
  `INSERT INTO platform_identity_builder_project_activity
680
- (client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at)
681
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now())
682
+ (client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at, reported_as_of)
683
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now(),
684
+ CASE WHEN $9::timestamptz IS NULL THEN NULL ELSE LEAST($9::timestamptz, now()) END)
682
685
  ON CONFLICT (client_id, github_id) DO UPDATE SET
683
686
  credits = EXCLUDED.credits, tasks_shipped = EXCLUDED.tasks_shipped, karma = EXCLUDED.karma,
684
687
  rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
685
- recent_ships = EXCLUDED.recent_ships,
688
+ recent_ships = EXCLUDED.recent_ships, reported_as_of = EXCLUDED.reported_as_of,
686
689
  last_active = now(), updated_at = now()
690
+ WHERE EXCLUDED.reported_as_of IS NULL OR platform_identity_builder_project_activity.reported_as_of IS NULL
691
+ OR EXCLUDED.reported_as_of >= platform_identity_builder_project_activity.reported_as_of
687
692
  RETURNING id, client_id, github_id`,
688
693
  // jsonb is bound as a JSON STRING: node-postgres serializes a JS array as a
689
694
  // Postgres ARRAY literal, which the jsonb column would reject.
690
- [clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships)],
695
+ [clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships), asOf],
691
696
  );
692
697
  // Windowed data plane (task 1002977) — best-effort, see activity-snapshots.js.
693
698
  await recordActivitySnapshot({ clientId, githubId }, { client });
@@ -83,6 +83,14 @@ const log = api.logger('platform-identity');
83
83
 
84
84
  const { pool, validateOrRespond, resolveSession } = api;
85
85
 
86
+ // The Bongos-Report-As-Of header of a rollup as an ISO timestamp, or null when it
87
+ // is absent or unparseable (task 1004268; see POST /sso/activity/rollup).
88
+ function reportAsOf(req) {
89
+ const raw = req.get ? req.get('Bongos-Report-As-Of') : null;
90
+ const ms = typeof raw === 'string' && raw.length <= 64 ? Date.parse(raw) : NaN;
91
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
92
+ }
93
+
86
94
  // Minimal standalone error page for the browser leg (no template engine / deps).
87
95
  // The /sso/authorize failures are pre-redirect (bad client / bad redirect_uri), so
88
96
  // we must render our OWN page rather than bounce to an unvalidated URI.
@@ -538,6 +546,15 @@ module.exports = function ssoRoutes() {
538
546
  // the AUTHENTICATED client_id. Descriptive analytics for the Archon-only scouting
539
547
  // view — never authority.
540
548
  //
549
+ // Optional `Bongos-Report-As-Of` header (task 1004268): when the instance read
550
+ // these totals, so an older report landing last cannot overwrite a newer one.
551
+ // A HEADER, not a body field, because the body is validated strictly and the hub
552
+ // and its instances upgrade independently: a new instance's body field would 400
553
+ // on an older hub and the report would be lost, while an unknown header is
554
+ // ignored. Absent or unparseable reads as unstamped (last-writer-wins, as before)
555
+ // rather than a 400 — the sender never reads the response, so a refusal would
556
+ // silently drop the report instead of only its ordering.
557
+ //
541
558
  // rank: public — authenticated by client_id + client_secret, not a builder rank.
542
559
  router.post('/sso/activity/rollup', async (req, res) => {
543
560
  if (validateOrRespond(req, res, {
@@ -595,6 +612,7 @@ module.exports = function ssoRoutes() {
595
612
  // ship link must fall inside (task 1002803). Taken from the client row,
596
613
  // never from the body, for the same reason client_id is.
597
614
  clientOrigin: client.origin,
615
+ asOf: reportAsOf(req),
598
616
  }, { pool });
599
617
  res.json({ ok: true });
600
618
  } catch (err) {
@@ -595,8 +595,9 @@ await ta('upsertActivity upserts per (client_id, github_id) with the talent snap
595
595
  assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
596
596
  // recent_ships (task 1002803) rides the same row as a jsonb STRING param —
597
597
  // an empty list when the sender reported none, never NULL. Its own cap,
598
- // shape and link bound are covered by tests/hub_recent_ships.mjs.
599
- assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer'], '[]']);
598
+ // shape and link bound are covered by tests/hub_recent_ships.mjs. The last param
599
+ // is the report's as-of stamp (task 1004268): null when the sender gave none.
600
+ assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer'], '[]', null]);
600
601
  });
601
602
 
602
603
  await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-ladder rank → null, disciplines validated + capped', async () => {
@@ -65,10 +65,10 @@
65
65
  <h2>Your work across projects</h2>
66
66
  <p>Each {{productName}} project you build in reports a summary of your work there to the platform: the {{currency}} you earned, the works you shipped, your karma, the rank that project gave you, the roles you worked in, and when you were last active. The platform adds these up across every project into one cross-project record. Projects report this themselves — the platform does not read their databases.</p>
67
67
  <p><b>These numbers describe; they do not govern.</b> Your cross-project record is descriptive analytics and never authority. What you may do inside any project is decided by that project alone, from its own records, at the moment you ask. A large record grants nothing and a hidden one takes nothing away; no project is required to read it, and none may treat it as permission. A rank shown here is that project&rsquo;s own standing for you, as that project reported it &mdash; the platform holds no rank of its own.</p>
68
- <p><b>What is public.</b> Once you have signed in to a project with your platform account and accepted these terms, your profile and that record are public by default: at your handle&rsquo;s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in to a project that way stays provisional and is published nowhere.</p>
68
+ <p><b>What is public.</b> Once you have signed in to a project in your browser with your platform account and accepted these terms, your profile and that record are public by default: at your handle&rsquo;s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in to a project that way stays provisional and is published nowhere.</p>
69
69
  <p><b>What you control.</b> From your settings you can hide your statistics, drop out of the recruiting directory, or make your account private. Each takes effect immediately, everywhere, including on a page someone already has open. Hiding removes the platform&rsquo;s combined view; it does not retract the per-project numbers each project publishes itself, so a determined reader could still add them up the hard way.</p>
70
- <p><b>Your acceptance is recorded.</b> The platform stores the date you accepted these terms, and which version, on your account. It is written when you sign in to a project with your platform account, and only once &mdash; signing in again does not change the date you accepted. This is version <b>2026-08-29</b>.</p>
71
- <p><b>Accounts from before these terms.</b> If your account already existed when this version took effect, the platform accepted it on your behalf, rather than waiting for you to sign in, and dated it the day that was done. The record marks that acceptance as grandfathered, so it is never mistaken for one you gave, and your own acceptance replaces it the next time you sign in to a project.</p>
70
+ <p><b>Your acceptance is recorded.</b> The platform stores the date you accepted these terms, and which version, on your account. It is written when you sign in to a project in your browser with your platform account, and only once &mdash; signing in again does not change the date you accepted. Signing in from the command line does not record it: you approve that sign-in on GitHub&rsquo;s page, where these terms are not shown, so an account that has only signed in that way stays unpublished until you sign in to a project in your browser once. This is version <b>2026-08-29</b>.</p>
71
+ <p><b>Accounts from before these terms.</b> If your account already existed when this version took effect, the platform accepted it on your behalf, rather than waiting for you to sign in, and dated it the day that was done. The record marks that acceptance as grandfathered, so it is never mistaken for one you gave, and your own acceptance replaces it the next time you sign in to a project in your browser.</p>
72
72
  <!--apex-only:end-->
73
73
  <h2>Fair use</h2>
74
74
  <p>Don't use {{productName}} to break the law, to attack this site or other people's projects, to mine, spam or otherwise abuse hosted resources, or to harass other builders. The owner may deactivate a builder or take a project offline for breaking these, and may set further house rules inside the hall.</p>
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.17",
3
+ "version": "1.20.19",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.17",
9
+ "version": "1.20.19",
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.20.17",
3
+ "version": "1.20.19",
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",
@@ -8158,5 +8158,17 @@
8158
8158
  "id": "1003540",
8159
8159
  "text": "Guessing someone's private profile name on the recruiting page is now speed-limited, so it can't be used to discover who is open to recruiting."
8160
8160
  }
8161
+ ],
8162
+ "1.20.18": [
8163
+ {
8164
+ "id": "1004265",
8165
+ "text": "The terms page now says your agreement is recorded when you sign in to a project in your browser, and explains why a command-line-only sign-in doesn't publish your profile."
8166
+ }
8167
+ ],
8168
+ "1.20.19": [
8169
+ {
8170
+ "id": "1004268",
8171
+ "text": "Builders' activity numbers on the platform can no longer briefly go backwards when two updates arrive at the same time."
8172
+ }
8161
8173
  ]
8162
8174
  }
@@ -87,6 +87,10 @@ const INTEGRATION = new Set([
87
87
  // WHERE NOT EXISTS statement) and the upsertActivity wiring against real SQL —
88
88
  // a fake pool can prove the query shape but not which reports it inserts.
89
89
  'activity_snapshots_db',
90
+ // task 1004268: an older rollup report never overwrites a newer one — a WHERE on
91
+ // the upsert's conflict branch, whose NULL handling and racing-writer outcome only
92
+ // a real Postgres settles (the DB-free sibling is activity_rollup_as_of).
93
+ 'activity_rollup_order_db',
90
94
  // task 1002978: the windowed leaderboard against real SQL. Which snapshot
91
95
  // becomes a window baseline (a LATERAL at-or-before probe), a missing baseline
92
96
  // counting as zero, and the privacy matrix filtering are all planner facts the
@@ -148,6 +148,10 @@ async function reportActivityRollup({ builderId, githubId = null }) {
148
148
  const idp = loadIdpConfig();
149
149
  if (!idp) return;
150
150
  try {
151
+ // Stamped BEFORE the reads: the hub keeps whichever report was read latest, so a
152
+ // sign-in push and a ship push racing to it cannot land the older totals last
153
+ // (task 1004268). A header, not a body field — see the hub's rollup route.
154
+ const asOf = new Date().toISOString();
151
155
  // Snapshot the builder's talent signals directly from the DB (auth.js is a
152
156
  // kernel file — it uses pool, never a non-kernel domain module like metrics.js).
153
157
  const b = await pool.query(
@@ -162,7 +166,10 @@ async function reportActivityRollup({ builderId, githubId = null }) {
162
166
  const recentShips = await recentShipsForRollup(builderId);
163
167
  await fetch(`${idp.origin}/api/bongos/sso/activity/rollup`, {
164
168
  method: 'POST',
165
- headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
169
+ headers: {
170
+ 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent(),
171
+ 'Bongos-Report-As-Of': asOf,
172
+ },
166
173
  body: JSON.stringify({
167
174
  client_id: idp.clientId,
168
175
  client_secret: idp.clientSecret,
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.17'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.19'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,172 @@
1
+ // tests/activity_rollup_as_of.mjs — task 1004268, the two ends of the wire.
2
+ //
3
+ // A rollup report now says WHEN the instance read its totals, so the hub can keep
4
+ // the newest one when a sign-in push and a ship push race (the ordering itself is
5
+ // proven against real SQL in tests/activity_rollup_order_db.mjs). This file holds
6
+ // the two halves a fake can settle:
7
+ //
8
+ // 1. THE SENDER stamps every report with a `Bongos-Report-As-Of` header, taken
9
+ // BEFORE it reads the totals, and never puts the stamp in the body — the hub
10
+ // validates the body strictly, so a body field from a newer instance would
11
+ // 400 on an older hub and lose the report.
12
+ // 2. THE HUB ROUTE binds that header as the upsert's stamp, and reads a missing
13
+ // or unparseable one as "unstamped" (last-writer-wins, the old behaviour)
14
+ // rather than refusing — the sender never reads the response, so a 400 would
15
+ // silently drop the report, not just its ordering.
16
+ //
17
+ // DB-free: the core half pins require.cache and stubs fetch (the
18
+ // tests/hub_recent_ships.mjs idiom); the hub half drives the real route handler
19
+ // over a recording fake pool.
20
+ //
21
+ // Run: node tests/activity_rollup_as_of.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { createRequire } from 'node:module';
25
+
26
+ const require = createRequire(import.meta.url);
27
+
28
+ let passed = 0, failed = 0;
29
+ async function t(name, fn) {
30
+ try { await fn(); passed++; console.log(` ok ${name}`); }
31
+ catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
32
+ }
33
+
34
+ function mockPool(answer = () => ({ rows: [] })) {
35
+ const calls = [];
36
+ const query = async (text, params = []) => {
37
+ calls.push({ text: String(text), params, at: Date.now() });
38
+ return answer(String(text), params) || { rows: [] };
39
+ };
40
+ return {
41
+ calls,
42
+ query,
43
+ connect: async () => ({ query, release: () => {} }),
44
+ find: (re) => calls.find((c) => re.test(c.text)),
45
+ };
46
+ }
47
+
48
+ // ---- 1. the sender ----------------------------------------------------------
49
+
50
+ console.log('\n1. the instance stamps each report, in a header:');
51
+
52
+ const pin = (rel, exports) => {
53
+ const p = require.resolve(rel);
54
+ require.cache[p] = { id: p, filename: p, loaded: true, exports };
55
+ };
56
+
57
+ await t('reportActivityRollup sends Bongos-Report-As-Of, stamped before the totals were read', async () => {
58
+ const senderPool = mockPool((sql) => {
59
+ if (/FROM builders WHERE id/.test(sql)) {
60
+ return { rows: [{ github_id: 42, total_credits: 7, karma: 1, rank: 'metic', preferred_disciplines: ['engineer'] }] };
61
+ }
62
+ if (/count\(\*\)/.test(sql)) return { rows: [{ n: 3 }] };
63
+ return { rows: [] };
64
+ });
65
+ pin('../src/bongos/pool.js', { pool: senderPool });
66
+ pin('../src/bongos/auth-config.js', {
67
+ loadIdpConfig: () => ({ origin: 'https://hub.example', clientId: 'c1', clientSecret: 's' }),
68
+ });
69
+ delete require.cache[require.resolve('../src/bongos/auth-admission.js')];
70
+ const { reportActivityRollup } = require('../src/bongos/auth-admission.js');
71
+
72
+ const sent = [];
73
+ const realFetch = globalThis.fetch;
74
+ globalThis.fetch = async (url, opts) => { sent.push({ url, opts }); return { ok: true, json: async () => ({ ok: true }) }; };
75
+ const before = Date.now();
76
+ try {
77
+ await reportActivityRollup({ builderId: 7 });
78
+ } finally {
79
+ globalThis.fetch = realFetch;
80
+ }
81
+ assert.equal(sent.length, 1, 'exactly one rollup was sent');
82
+ assert.match(sent[0].url, /\/sso\/activity\/rollup$/);
83
+ const stamp = sent[0].opts.headers['Bongos-Report-As-Of'];
84
+ assert.equal(typeof stamp, 'string', 'the report carries its as-of header');
85
+ const ms = Date.parse(stamp);
86
+ assert.ok(Number.isFinite(ms) && new Date(ms).toISOString() === stamp, `an ISO timestamp, got ${stamp}`);
87
+ assert.ok(ms >= before && ms <= senderPool.calls[0].at,
88
+ 'stamped BEFORE the first read — a stamp taken after could order a report ahead of totals read later');
89
+ const body = JSON.parse(sent[0].opts.body);
90
+ assert.equal(body.credits, 7, 'CONTROL: the body still carries the totals');
91
+ assert.ok(!Object.keys(body).some((k) => /as_?of/i.test(k)),
92
+ 'the stamp must never ride the body: an older hub validates it strictly and would 400 the whole report');
93
+ });
94
+
95
+ // ---- 2. the hub route --------------------------------------------------------
96
+
97
+ console.log('\n2. the hub binds the stamp, and an unusable one degrades to unstamped:');
98
+
99
+ const SECRET = 'as-of-secret';
100
+ function hubRoute() {
101
+ const pi = require('../modules/platform-identity/platform-identity.js');
102
+ const rollupPool = mockPool((sql) => {
103
+ if (/FROM platform_identity_sso_clients/.test(sql)) {
104
+ return { rows: [{ client_id: 'c1', secret_hash: pi.sha256hex(SECRET), status: 'active', origin: 'https://p.example' }] };
105
+ }
106
+ if (/FROM platform_identity_project_memberships/.test(sql)) {
107
+ return { rows: [{ id: 1, client_id: 'c1', github_id: 42, membership_kind: 'member', signin_verified_at: '2026-08-29T00:00:00.000Z' }] };
108
+ }
109
+ return { rows: [{ id: 1 }] };
110
+ });
111
+ const helpers = require('../src/bongos/routes/_helpers.js');
112
+ const realApi = require('../src/module-api.js');
113
+ const moduleApiPath = require.resolve('../src/module-api.js');
114
+ require.cache[moduleApiPath] = {
115
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
116
+ exports: {
117
+ pool: rollupPool,
118
+ validateOrRespond: helpers.validateOrRespond,
119
+ resolveSession: async () => null,
120
+ logger: () => ({ info() {}, warn() {}, error() {} }),
121
+ LIVE_RANK_LADDER: realApi.LIVE_RANK_LADDER,
122
+ withTx: realApi.withTx,
123
+ corsPublicGet: () => (_q, _s, next) => next(),
124
+ on: () => () => {},
125
+ hasProvider: () => false,
126
+ registerProvider: () => {},
127
+ },
128
+ };
129
+ delete require.cache[require.resolve('../modules/platform-identity/routes/sso.js')];
130
+ const router = require('../modules/platform-identity/routes/sso.js')();
131
+ const layer = router.stack.find((l) => l.route && l.route.path === '/sso/activity/rollup');
132
+ return { handle: layer.route.stack[0].handle, rollupPool };
133
+ }
134
+
135
+ async function postRollup(headers) {
136
+ const { handle, rollupPool } = hubRoute();
137
+ const res = { body: null, failure: null };
138
+ res.json = (b) => { res.body = b; return res; };
139
+ res.fail = (code, ...rest) => { res.failure = { code, rest }; return res; };
140
+ const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
141
+ const req = {
142
+ get: (name) => lower[name.toLowerCase()],
143
+ body: { client_id: 'c1', client_secret: SECRET, github_id: 42, credits: 10, tasks_shipped: 2, karma: 1 },
144
+ };
145
+ await handle(req, res, () => {});
146
+ const insert = rollupPool.find(/INSERT INTO platform_identity_builder_project_activity/);
147
+ return { res, stamp: insert ? insert.params[8] : undefined };
148
+ }
149
+
150
+ await t('a stamped report binds its as-of as the upsert stamp', async () => {
151
+ const { res, stamp } = await postRollup({ 'Bongos-Report-As-Of': '2026-09-30T12:00:10.123Z' });
152
+ assert.equal(res.failure, null, JSON.stringify(res.failure));
153
+ assert.deepEqual(res.body, { ok: true });
154
+ assert.equal(stamp, '2026-09-30T12:00:10.123Z');
155
+ });
156
+
157
+ await t('no header (an older instance) is unstamped — the report still applies', async () => {
158
+ const { res, stamp } = await postRollup({});
159
+ assert.deepEqual(res.body, { ok: true });
160
+ assert.equal(stamp, null);
161
+ });
162
+
163
+ await t('an unparseable or oversized header is unstamped, never a 400 that would drop the report', async () => {
164
+ for (const bad of ['yesterday-ish', '', 'x'.repeat(200)]) {
165
+ const { res, stamp } = await postRollup({ 'Bongos-Report-As-Of': bad });
166
+ assert.equal(res.failure, null, `a bad stamp (${bad.slice(0, 20)}) refused the whole report`);
167
+ assert.equal(stamp, null, `a bad stamp (${bad.slice(0, 20)}) was bound`);
168
+ }
169
+ });
170
+
171
+ console.log(`\n${passed} passed, ${failed} failed`);
172
+ if (failed) process.exit(1);
@@ -0,0 +1,131 @@
1
+ // tests/activity_rollup_order_db.mjs — task 1004268 (real DB).
2
+ //
3
+ // A rollup report computed EARLIER never overwrites one computed LATER. The
4
+ // instance stamps each report with the moment it read the totals (the
5
+ // Bongos-Report-As-Of header, carried here as upsertActivity's `asOf`), and the
6
+ // live row's ON CONFLICT DO UPDATE only fires when the incoming stamp is at least
7
+ // the stored one. Before this, a sign-in report and a ship report racing to the
8
+ // hub were last-writer-wins, so the older totals could commit last and the
9
+ // leaderboard would read a regression that never happened.
10
+ //
11
+ // Only a real Postgres can settle this: the guard is a WHERE on the conflict
12
+ // branch, and what it does with NULLs (a report from an instance too old to stamp,
13
+ // a row written before the column existed) and with a skipped update (does the
14
+ // snapshot beside it still agree with the live row?) is planner behaviour, not
15
+ // statement shape.
16
+ //
17
+ // Real-DB integration test: self-skips without a reachable Postgres; in the
18
+ // INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
19
+ // never runs it.
20
+ //
21
+ // Run: DATABASE_URL=... node tests/activity_rollup_order_db.mjs
22
+
23
+ import { createRequire } from 'node:module';
24
+ import { readFileSync } from 'node:fs';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { dirname, join } from 'node:path';
27
+ import { strict as assert } from 'node:assert';
28
+ const require = createRequire(import.meta.url);
29
+ const { Pool } = require('pg');
30
+ const pi = require('../modules/platform-identity/platform-identity.js');
31
+
32
+ const here = dirname(fileURLToPath(import.meta.url));
33
+ const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
34
+ const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
35
+
36
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
37
+ try {
38
+ await pool.query('SELECT 1');
39
+ } catch (e) {
40
+ console.log(`activity_rollup_order_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
41
+ await pool.end().catch(() => {});
42
+ process.exit(0);
43
+ }
44
+
45
+ // High-numbered fixture ids so a shared DB can never collide with real data.
46
+ const ORDER = 90042681, LEGACY = 90042682, FUTURE = 90042683, RACE = 90042684;
47
+ const IDS = [ORDER, LEGACY, FUTURE, RACE];
48
+ const CLIENT = 't1004268-client';
49
+ const q = (sql, params) => pool.query(sql, params);
50
+ const report = (githubId, credits, asOf) =>
51
+ pi.upsertActivity({ clientId: CLIENT, githubId, credits, tasksShipped: 1, karma: 0, asOf }, { pool });
52
+ const at = (sec) => new Date(Date.UTC(2026, 8, 30, 12, 0, sec)).toISOString();
53
+
54
+ async function cleanup() {
55
+ await q('DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])', [IDS]).catch(() => {});
56
+ await q('DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])', [IDS]).catch(() => {});
57
+ }
58
+ async function live(githubId) {
59
+ const { rows } = await q(
60
+ `SELECT credits, reported_as_of FROM platform_identity_builder_project_activity
61
+ WHERE client_id = $1 AND github_id = $2`, [CLIENT, githubId]);
62
+ return rows[0];
63
+ }
64
+ async function newestSnapshot(githubId) {
65
+ const { rows } = await q(
66
+ `SELECT credits FROM platform_identity_activity_snapshots
67
+ WHERE client_id = $1 AND github_id = $2 ORDER BY reported_at DESC, id DESC LIMIT 1`, [CLIENT, githubId]);
68
+ return rows[0] ? rows[0].credits : null;
69
+ }
70
+
71
+ let pass = 0;
72
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
73
+ const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
74
+
75
+ try {
76
+ for (const f of ['platform_identity_005_builder_activity.sql', 'platform_identity_012_recent_ships.sql',
77
+ 'platform_identity_022_activity_snapshots.sql', 'platform_identity_026_activity_reported_as_of.sql']) {
78
+ await q(migrationSql(f));
79
+ }
80
+ await q(migrationSql('platform_identity_026_activity_reported_as_of.sql')); // idempotent: replays cleanly
81
+ await cleanup();
82
+
83
+ // 1 — the race the task describes, landed in the bad order.
84
+ await report(ORDER, 200, at(20)); // the ship report, computed later, lands first
85
+ await report(ORDER, 100, at(10)); // the sign-in report, computed earlier, lands last
86
+ same((await live(ORDER)).credits, 200, 'an older report landing last does not overwrite the newer totals');
87
+ same(await newestSnapshot(ORDER), 200, 'and the snapshot history does not record the regression either');
88
+ await report(ORDER, 300, at(30));
89
+ same((await live(ORDER)).credits, 300, 'a genuinely newer report still applies');
90
+ await report(ORDER, 250, at(30));
91
+ same((await live(ORDER)).credits, 250, 'an equal stamp applies (ties go to the later arrival, as before)');
92
+
93
+ // 2 — mixed versions. An instance too old to stamp sends no as_of: last-writer-wins,
94
+ // exactly as before, and it clears the guard rather than leaving a stamp its numbers
95
+ // no longer match.
96
+ await report(LEGACY, 10, null);
97
+ same((await live(LEGACY)).reported_as_of, null, 'an unstamped report stores no stamp');
98
+ await report(LEGACY, 20, at(10));
99
+ same((await live(LEGACY)).credits, 20, 'a stamped report over an unstamped row applies');
100
+ await report(LEGACY, 5, null);
101
+ same([(await live(LEGACY)).credits, (await live(LEGACY)).reported_as_of], [5, null],
102
+ 'an unstamped report always applies and clears the stamp (the old behaviour, for old instances)');
103
+ await report(LEGACY, 30, at(1));
104
+ same((await live(LEGACY)).credits, 30, 'after it, the next stamped report applies whatever its stamp');
105
+
106
+ // 3 — a sender whose clock runs ahead cannot freeze the row: a stamp past the hub's
107
+ // own now() is stored as now(), so the next honest report is not refused until then.
108
+ await report(FUTURE, 1, new Date(Date.now() + 365 * 86400e3).toISOString());
109
+ const stored = new Date((await live(FUTURE)).reported_as_of).getTime();
110
+ ok(stored <= Date.now() + 1000, 'a future stamp is clamped to the hub clock');
111
+ await report(FUTURE, 2, new Date(Date.now() + 2000).toISOString());
112
+ same((await live(FUTURE)).credits, 2, 'so the next report from a correct clock still applies');
113
+
114
+ // 4 — racing writers: the row lock queues them, and whatever the commit order the
115
+ // row ends on the report with the LATEST stamp, and the newest snapshot agrees.
116
+ for (let round = 0; round < 10; round++) {
117
+ await q('DELETE FROM platform_identity_activity_snapshots WHERE github_id = $1', [RACE]);
118
+ await q('DELETE FROM platform_identity_builder_project_activity WHERE github_id = $1', [RACE]);
119
+ const stamps = [1, 2, 3, 4, 5].sort(() => Math.random() - 0.5);
120
+ await Promise.all(stamps.map((s) => report(RACE, s * 100, at(s))));
121
+ const row = await live(RACE);
122
+ assert.equal(row.credits, 500, `round ${round}: the row ended on ${row.credits}, not the latest-stamped 500`);
123
+ assert.equal(await newestSnapshot(RACE), 500, `round ${round}: the newest snapshot disagrees with the live row`);
124
+ }
125
+ pass++; console.log(' ok - 10 rounds of five racing reports in random order: the latest stamp always wins');
126
+
127
+ console.log(`\nactivity_rollup_order_db.mjs: ${pass} passed`);
128
+ } finally {
129
+ await cleanup();
130
+ await pool.end().catch(() => {});
131
+ }
@@ -97,6 +97,8 @@ try {
97
97
  // column); without it here, the upsertActivity steps fail 42703 on a fresh database.
98
98
  await q(migrationSql('platform_identity_012_recent_ships.sql'));
99
99
  await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
100
+ // upsertActivity also writes reported_as_of (task 1004268) — same reason as 012 above.
101
+ await q(migrationSql('platform_identity_026_activity_reported_as_of.sql'));
100
102
  await cleanup();
101
103
 
102
104
  // 1 — the writer snapshots the LIVE row, and only when it changed.
@@ -100,6 +100,8 @@ try {
100
100
  // before the fixtures, so its one-shot grandfather never sees LEGACY below.
101
101
  await q(migrationSql('platform_identity_024_terms_grandfathered.sql'));
102
102
  await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
103
+ // upsertActivity also writes reported_as_of (026, task 1004268): 42703 without it.
104
+ await q(migrationSql('platform_identity_026_activity_reported_as_of.sql'));
103
105
  await cleanup();
104
106
 
105
107
  // CONSENT IS TWO COLUMNS (ADR 0213, task 1002913): profile_state='active' AND
@@ -118,20 +118,32 @@ t('the acceptance the page promises is the one the code actually keeps', () => {
118
118
  });
119
119
 
120
120
  // The capture point is GET /sso/authorize — reached when a builder uses their
121
- // platform account to sign in to a PROJECT. Signing in to the hub itself stamps
122
- // nothing (blocker 1000124, task 1003353), so the page must not say "here".
123
- // Task 1003361 adds the hub's own sign-in as a second capture point; when it
124
- // lands, this wording and §3 below change together.
125
- t('the page names the sign-in the code actually watches — a project sign-in, not "here" (blocker 1000124)', () => {
121
+ // platform account to sign in to a PROJECT, IN A BROWSER. Signing in to the hub
122
+ // itself stamps nothing (blocker 1000124, task 1003353), so the page must not say
123
+ // "here"; and the CLI's project sign-in (the device poll) stamps nothing either
124
+ // (task 1004265, executed in §3), so the page must say "in your browser" and tell a
125
+ // command-line builder why. Task 1003361 adds the hub's own sign-in as a second
126
+ // capture point; when it lands, this wording and §3 below change together.
127
+ t('the page names the sign-in the code actually watches — a project sign-in in the browser, not "here" (blocker 1000124)', () => {
126
128
  const section = termsHtml.slice(termsHtml.indexOf('Your work across projects'), termsHtml.indexOf('Fair use'));
127
129
  assert.doesNotMatch(section, /sign(?:ed|ing)? in here/i,
128
130
  'the page promises that signing in to this site records acceptance — the code never stamps that sign-in');
129
- assert.match(section, /Once you have signed in to a project with your platform account and accepted these terms/,
131
+ assert.match(section, /Once you have signed in to a project in your browser with your platform account and accepted these terms/,
130
132
  'what makes a profile public must name the moment the code captures');
131
133
  assert.match(section, /never signed in to a project that way stays provisional/,
132
134
  'and the provisional case must be the same moment, negated');
133
- assert.match(section, /written when you sign in to a project with your platform account, and only once/,
135
+ assert.match(section, /written when you sign in to a project in your browser with your platform account, and only once/,
134
136
  'the acceptance paragraph must name the same capture point');
137
+ assert.doesNotMatch(section, /sign(?:ed)? in to a project with your platform account/,
138
+ 'an unqualified "project sign-in" also covers the command line, which records nothing (task 1004265)');
139
+ });
140
+
141
+ t('the page tells a command-line-only builder that their sign-in records nothing, and why (task 1004265)', () => {
142
+ const section = termsHtml.slice(termsHtml.indexOf('Your work across projects'), termsHtml.indexOf('Fair use'));
143
+ assert.match(section, /Signing in from the command line does not record it/,
144
+ 'a CLI-only builder stays unpublished, and the page must say so rather than leave them to guess');
145
+ assert.match(section, /on GitHub&rsquo;s page, where these terms are not shown/,
146
+ 'and why: the device approval never puts these terms in front of them');
135
147
  });
136
148
 
137
149
  t('the page says which acceptances were given on a builder’s behalf, and that the record marks them (task 1003360)', () => {
@@ -139,7 +151,7 @@ t('the page says which acceptances were given on a builder’s behalf, and that
139
151
  'the owner chose to grandfather AND to say so on the page — the page must tell a pre-existing builder what was recorded for them');
140
152
  assert.match(termsHtml, /marks that acceptance as grandfathered/i,
141
153
  'and that the record keeps it distinguishable from one they gave');
142
- assert.match(termsHtml, /your own acceptance replaces it the next time you sign in to a project/i,
154
+ assert.match(termsHtml, /your own acceptance replaces it the next time you sign in to a project in your browser/i,
143
155
  'and what happens when they do sign in — the conflict branch below really does replace it');
144
156
  assert.doesNotMatch(termsHtml, /written only by signing in/i,
145
157
  'the page still says acceptance is written ONLY by signing in, which is false for every grandfathered account');
@@ -503,6 +515,100 @@ await ta('a session of an undeclared source (a leftover dev-box token) cannot mi
503
515
  assert.equal(pool.accounts.size, 0, 'a session of an undeclared source recorded a platform T&C acceptance');
504
516
  });
505
517
 
518
+ // The CLI's project sign-in (POST /sso/device/poll) also uses the platform account,
519
+ // and it deliberately records NOTHING (task 1004265). The approval happens on
520
+ // GitHub's device page, never on a hub page, so these terms are never in front of
521
+ // the person — and activation is ADR 0171 D3's "a human is demonstrably at the hub",
522
+ // which the same branch stamps. So the page names a BROWSER sign-in and says why a
523
+ // command-line one does not count; this drives the real route to hold that claim.
524
+ function post(path_, body) {
525
+ const payload = JSON.stringify(body);
526
+ return new Promise((resolve, reject) => {
527
+ const req = http.request({
528
+ host: '127.0.0.1', port, method: 'POST', path: path_,
529
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
530
+ }, (res) => {
531
+ const chunks = [];
532
+ res.on('data', (c) => chunks.push(c));
533
+ res.on('end', () => {
534
+ const text = Buffer.concat(chunks).toString('utf8');
535
+ let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
536
+ resolve({ status: res.statusCode, json });
537
+ });
538
+ });
539
+ req.on('error', reject);
540
+ req.end(payload);
541
+ });
542
+ }
543
+
544
+ // GitHub's side of the device flow, answered as approved for `identity`. The route
545
+ // reaches both modules through their exports objects, so this swaps the network leg
546
+ // and the signing key only — the route, its upsert and the fake pool stay real.
547
+ async function withApprovedDevice(identity, fn) {
548
+ const hubDevice = require('../modules/platform-identity/hub-device.js');
549
+ const hubKeys = require('../modules/platform-identity/hub-keys.js');
550
+ const saved = {
551
+ hubDeviceConfigured: hubDevice.hubDeviceConfigured,
552
+ pollHubGithubDevice: hubDevice.pollHubGithubDevice,
553
+ hubSigningConfigured: hubKeys.hubSigningConfigured,
554
+ mintHubAssertion: hubKeys.mintHubAssertion,
555
+ };
556
+ hubDevice.hubDeviceConfigured = () => true;
557
+ hubDevice.pollHubGithubDevice = async () => ({ state: 'ok', client_id: 'proj-a', identity });
558
+ hubKeys.hubSigningConfigured = () => true;
559
+ hubKeys.mintHubAssertion = () => 'ASSERTION';
560
+ try { return await fn(); } finally {
561
+ hubDevice.hubDeviceConfigured = saved.hubDeviceConfigured;
562
+ hubDevice.pollHubGithubDevice = saved.pollHubGithubDevice;
563
+ hubKeys.hubSigningConfigured = saved.hubSigningConfigured;
564
+ hubKeys.mintHubAssertion = saved.mintHubAssertion;
565
+ }
566
+ }
567
+
568
+ const CLI_IDENTITY = { github_id: '5151', github_login: 'cli-only', display_name: 'Cli Only', avatar_url: null };
569
+
570
+ await ta('a CLI-only project sign-in (the device poll) signs the builder in and records NO acceptance (task 1004265)', async () => {
571
+ SESSION = null;
572
+ pool.accounts.clear();
573
+ pool.calls.length = 0;
574
+ pool.setClock(NOW);
575
+ const res = await withApprovedDevice(CLI_IDENTITY, () => post('/sso/device/poll', { device_code: 'dc-1' }));
576
+ assert.equal(res.status, 200, 'CONTROL: the device poll must succeed, or the absence below proves nothing');
577
+ assert.equal(res.json && res.json.assertion, 'ASSERTION', 'CONTROL: the CLI got its sign-in assertion');
578
+ const row = pool.accounts.get('5151');
579
+ assert.ok(row, 'CONTROL: the device poll writes the platform account — the absence below is of the stamp, not the row');
580
+ assert.equal(row.terms_accepted_at, null,
581
+ 'a command-line sign-in stamped an acceptance — nobody was shown the terms on that path; if this is now intended, /terms and ADR 0213 change with it');
582
+ assert.equal(row.terms_version, null);
583
+ assert.equal(row.profile_state, 'provisional', 'and it activates nothing (ADR 0171 D3: activation is a human at the hub)');
584
+ const upserts = pool.calls.filter((c) => /INSERT INTO platform_identity_accounts/i.test(c.sql) && /DO UPDATE/i.test(c.sql));
585
+ assert.equal(upserts.length, 1);
586
+ assert.equal(upserts[0].params[4], false, 'the device poll must pass the non-activating literal');
587
+ });
588
+
589
+ await ta('the device poll takes no acceptance from its body either — an extra field is refused before any write', async () => {
590
+ pool.accounts.clear();
591
+ const res = await withApprovedDevice(CLI_IDENTITY, () => post('/sso/device/poll', {
592
+ device_code: 'dc-2', activate: true, terms_accepted_at: '1999-01-01T00:00:00.000Z', terms_version: 'FORGED',
593
+ }));
594
+ assert.equal(res.status, 400, 'the strict validator must refuse fields the route does not declare');
595
+ assert.equal(pool.accounts.size, 0, 'a refused poll still wrote an account');
596
+ });
597
+
598
+ await ta('a CLI-only builder who later signs in to a project in the browser IS stamped then', async () => {
599
+ pool.accounts.clear();
600
+ pool.setClock(NOW);
601
+ await withApprovedDevice({ ...CLI_IDENTITY, github_id: '4242', github_login: 'octo' },
602
+ () => post('/sso/device/poll', { device_code: 'dc-3' }));
603
+ assert.equal(pool.accounts.get('4242').terms_accepted_at, null);
604
+ SESSION = HUB_SESSION;
605
+ pool.setClock(LATER);
606
+ assert.equal((await authorize()).status, 302);
607
+ const row = pool.accounts.get('4242');
608
+ assert.equal(row.terms_accepted_at, LATER, 'the browser sign-in the page names must stamp the account the CLI created');
609
+ assert.equal(row.profile_state, 'active');
610
+ });
611
+
506
612
  // ===========================================================================
507
613
  // 4. Idempotence — executed, not asserted against a string
508
614
  // ===========================================================================