@bongos/core 1.19.887 → 1.19.889

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.19.887",
6
- "core_contract": "1.19.887",
7
- "source_commit": "b874dffd8907b22a633526eddcb279c009c89b40",
5
+ "core_version": "1.19.889",
6
+ "core_contract": "1.19.889",
7
+ "source_commit": "b7871cd73a1ce5f73988d99ef63101a6a2d23050",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-19T20:48:51.300Z",
9
+ "built_at": "2026-09-19T21:23:27.620Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 497,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2264,
14
+ "functional_verbatim": 2269,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2787,
20
- "tree_sha256": "920b5751e940860113c8a2a84ede022e547e1901077f62330213144c8612091b",
19
+ "file_count": 2792,
20
+ "tree_sha256": "b7b8bc9e53808070c5d46a211481ceef0dea2c901bc79e2b0dc0871e60c362b1",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2607,7 +2607,7 @@
2607
2607
  {
2608
2608
  "path": "docs/module-api-changelog.md",
2609
2609
  "mode": "0000644",
2610
- "sha256": "31fdd25f81e095205720c92ce31100187f7d1c1c89eecc8b9c84f00936350352"
2610
+ "sha256": "01a90a40951ae88d4c36c5fabbaf3debc0e02bcaa9fa8a7b70e247aa5d61d236"
2611
2611
  },
2612
2612
  {
2613
2613
  "path": "docs/modules-contract.md",
@@ -2742,7 +2742,7 @@
2742
2742
  {
2743
2743
  "path": "docs/recipes/gds-db-backup.md",
2744
2744
  "mode": "0000644",
2745
- "sha256": "b3d6a3e3a400bf593b0f2d671f35cb1a60279f495c8763d311f8cdb68a6d603a"
2745
+ "sha256": "d3e628bc627cb29874e977bf2e93c6557dc72a210506c22515a983f4f513d627"
2746
2746
  },
2747
2747
  {
2748
2748
  "path": "docs/recipes/instance-deploy.md",
@@ -3699,6 +3699,11 @@
3699
3699
  "mode": "0000644",
3700
3700
  "sha256": "4f35f87050a532f26b7c9cd0a9bc6f298ab5c0e15eb1c36ddcdd4d261201291d"
3701
3701
  },
3702
+ {
3703
+ "path": "migrations/core_249_session_token_hash.sql",
3704
+ "mode": "0000644",
3705
+ "sha256": "ae3e405c7e59fbc13e23ae2a4ba945176f2369a150c997d9b8134e49991f273b"
3706
+ },
3702
3707
  {
3703
3708
  "path": "modules/agents/CLAUDE.md",
3704
3709
  "mode": "0000644",
@@ -3732,7 +3737,7 @@
3732
3737
  {
3733
3738
  "path": "modules/agents/lib/validate.js",
3734
3739
  "mode": "0000644",
3735
- "sha256": "3ff56e388e9ae4cccb09fb072793b5544f771aeae51e8477f8a221f0c78b9515"
3740
+ "sha256": "e9b3c905539a323d74a55f98455204bd46daca2daf49c476324e85684ba7971f"
3736
3741
  },
3737
3742
  {
3738
3743
  "path": "modules/agents/migrations/agents_001_definitions.sql",
@@ -3754,6 +3759,11 @@
3754
3759
  "mode": "0000644",
3755
3760
  "sha256": "31a5ac35536cecd264bc989907f4cf094e050db0b8de85e7e3e7831cf7c25a9d"
3756
3761
  },
3762
+ {
3763
+ "path": "modules/agents/migrations/agents_005_cron_trigger.sql",
3764
+ "mode": "0000644",
3765
+ "sha256": "5d422998b1d53ee015f4a494ccede1adcb1eb90bd501bd28c1d88f9840b87367"
3766
+ },
3757
3767
  {
3758
3768
  "path": "modules/agents/module.json",
3759
3769
  "mode": "0000644",
@@ -7692,12 +7702,12 @@
7692
7702
  {
7693
7703
  "path": "package-lock.json",
7694
7704
  "mode": "0000644",
7695
- "sha256": "356149fe3c3c69d00f16886662e5fee0ab51c4be066af64156a2b3bb7e14c69c"
7705
+ "sha256": "d1348f68cd79ad102a381e3d7f90b784d9df605e859269ede84ad859cc831deb"
7696
7706
  },
7697
7707
  {
7698
7708
  "path": "package.json",
7699
7709
  "mode": "0000644",
7700
- "sha256": "9e2d057866feafc0e9606c36335013a47ad55190c03e7beed63d1e592f2fd23a"
7710
+ "sha256": "36e895c05e2aed3f97ce34ae3b3f73b0492fff7185077519068bc69dda6a8fd6"
7701
7711
  },
7702
7712
  {
7703
7713
  "path": "public-docs/index.html",
@@ -8127,7 +8137,7 @@
8127
8137
  {
8128
8138
  "path": "scripts/gds/db-backup-offsite.sh",
8129
8139
  "mode": "0000644",
8130
- "sha256": "f455e4a6fdd69f0105104752c86099e3a17dad7b4802fa715191cedab9762cd2"
8140
+ "sha256": "97f52f6aff95e1c81e069c1d4228eecb514af6068c810441465b5f5b7ee0f2ce"
8131
8141
  },
8132
8142
  {
8133
8143
  "path": "scripts/gds/demote.js",
@@ -8802,7 +8812,7 @@
8802
8812
  {
8803
8813
  "path": "scripts/gds/run-unit-tests.js",
8804
8814
  "mode": "0000644",
8805
- "sha256": "8227fe5256b530d22192cd86f3624d52cc1dc34ac82099a8bb9e3d6a28482eb9"
8815
+ "sha256": "a40a90ba6255820130aa6a94ddc314b643177e4a153750dc6290550a75e1a4e8"
8806
8816
  },
8807
8817
  {
8808
8818
  "path": "scripts/gds/runner-drift.js",
@@ -9442,12 +9452,12 @@
9442
9452
  {
9443
9453
  "path": "src/bongos/db-kernel.js",
9444
9454
  "mode": "0000644",
9445
- "sha256": "1fffc39fb9b70cd009d7d239729a9ff329cd41ca1f35b50b7c9e6b89aa83624e"
9455
+ "sha256": "dd16328ac569aa8c6af09b2700957634e2c6d368d291b015a74a8dc452dc340a"
9446
9456
  },
9447
9457
  {
9448
9458
  "path": "src/bongos/db.js",
9449
9459
  "mode": "0000644",
9450
- "sha256": "e88c470ed9399be2930ec2d925e0864c30e355bf20d89486fb57dbe7561f6d78"
9460
+ "sha256": "def84475a0470dafb894e7ca3f96f9db27cea78964f90ac3e81ed748fe9efa48"
9451
9461
  },
9452
9462
  {
9453
9463
  "path": "src/bongos/github-repos.js",
@@ -9702,7 +9712,7 @@
9702
9712
  {
9703
9713
  "path": "src/module-api.js",
9704
9714
  "mode": "0000644",
9705
- "sha256": "309fd0ddeb0185da023052aaab8967162dbdb833ade7226f49e1636440146e83"
9715
+ "sha256": "541b09991f4858b3255f250ef7e52e86b0d561ec69d058e74bc218fe1a104932"
9706
9716
  },
9707
9717
  {
9708
9718
  "path": "src/module-loader/catalog.js",
@@ -9844,6 +9854,11 @@
9844
9854
  "mode": "0000644",
9845
9855
  "sha256": "eea8f9e0bdf2c84fabc81e16c30e4026a7a336c1e705d614e68b805957b9b74a"
9846
9856
  },
9857
+ {
9858
+ "path": "tests/agents_cron_trigger.mjs",
9859
+ "mode": "0000644",
9860
+ "sha256": "be78fa37657214c39a4880d2e1b44a1647c8b509d63262ca9a4a334431d7bdde"
9861
+ },
9847
9862
  {
9848
9863
  "path": "tests/agents_event_router.mjs",
9849
9864
  "mode": "0000644",
@@ -9877,7 +9892,7 @@
9877
9892
  {
9878
9893
  "path": "tests/agents_validate.mjs",
9879
9894
  "mode": "0000644",
9880
- "sha256": "9376185bc6e11571b050dcf61a993faf780cea66aa4c0844d114b1cb738043e2"
9895
+ "sha256": "164059d69fabbd5dd70a29b29c524b5359f0346b71091e73a5e4fafa5dbc0804"
9881
9896
  },
9882
9897
  {
9883
9898
  "path": "tests/agents_write_routes.mjs",
@@ -10082,7 +10097,7 @@
10082
10097
  {
10083
10098
  "path": "tests/auth_session_expiry_db.mjs",
10084
10099
  "mode": "0000644",
10085
- "sha256": "880cbfa0304ab037db980f22471a2b46bcdbad0e7dca57af359402dbb068d6a4"
10100
+ "sha256": "89730864ff5aa36271582c7e2717bf434bc84cbc20b615026005a1ef662ff31c"
10086
10101
  },
10087
10102
  {
10088
10103
  "path": "tests/auth_session_ttl.mjs",
@@ -11347,7 +11362,7 @@
11347
11362
  {
11348
11363
  "path": "tests/government_live_proof.mjs",
11349
11364
  "mode": "0000644",
11350
- "sha256": "6d829af6ef71bfa744a4ff457a4766b5cce021f317661579b06ecfc14298a523"
11365
+ "sha256": "ca29425b30e726e6a7e939b008f0e5ca71877fa88fdcbfe1bf6356482f47c1e8"
11351
11366
  },
11352
11367
  {
11353
11368
  "path": "tests/government_live_proof_shape.mjs",
@@ -12362,7 +12377,7 @@
12362
12377
  {
12363
12378
  "path": "tests/own_session_revoke.mjs",
12364
12379
  "mode": "0000644",
12365
- "sha256": "34ae2846e314ad4ba732a40257fb81f7b4e4be3e81e3b6d1f478c3bb0690f23d"
12380
+ "sha256": "ff24d3753a52a64f426e0602bc2411aad13c1426c2bc0774b7618a1d20cebfad"
12366
12381
  },
12367
12382
  {
12368
12383
  "path": "tests/package_core.mjs",
@@ -13224,6 +13239,16 @@
13224
13239
  "mode": "0000644",
13225
13240
  "sha256": "a630ac72981f230e8aa6609152765c13e8928ac329cb0ff3fe615371f31208d2"
13226
13241
  },
13242
+ {
13243
+ "path": "tests/session_token_hash.mjs",
13244
+ "mode": "0000644",
13245
+ "sha256": "ff4cea59acb15ce383d9346793206865f0aababc5e2f4144c957bb93d45f62b4"
13246
+ },
13247
+ {
13248
+ "path": "tests/session_token_hash_db.mjs",
13249
+ "mode": "0000644",
13250
+ "sha256": "4a757c27f11cde314649e46ab24e51600d24c2f503f1aa7f055cdb4fa60ea6ce"
13251
+ },
13227
13252
  {
13228
13253
  "path": "tests/session_type.mjs",
13229
13254
  "mode": "0000644",
@@ -2248,5 +2248,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2248
2248
  landed since 1.19.885 with no explicit bump. run 35467975408. (task 1002620)
2249
2249
  1.19.887 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2250
2250
  landed since 1.19.886 with no explicit bump. run 35468511941. (task 1002620)
2251
+ 1.19.888 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2252
+ landed since 1.19.887 with no explicit bump. run 35468948342. (task 1002620)
2253
+ 1.19.889 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2254
+ landed since 1.19.888 with no explicit bump. run 35470239834. (task 1002620)
2251
2255
  ---------------------------------------------------------------------------
2252
2256
  ```
@@ -46,8 +46,33 @@ gds-db-offsite.timer ──→ gds-db-offsite.service ──→ db-backup-offsit
46
46
  - **It does not take its own `pg_dump`.** It uploads the newest `/var/backups/gds/<db>-*.sql.gz` that the local nightly already produced. Runs at 05:00 UTC — one hour after the 04:00 local dump — so the copy is same-day-fresh.
47
47
  - **Bucket layout:** objects land under the `gds-offsite/` key prefix in the Space.
48
48
  - **Retention:** 4 weekly copies (`GDS_OFFSITE_KEEP=4` — ~1 month), pruned oldest-first under that prefix. Sub-1 MB each.
49
- - **Credentials** live at `/etc/example/spaces.env` (chmod 600, owned by `lars`), never in the repo (ADR 0022). Required vars: `SPACES_KEY`, `SPACES_SECRET`, `SPACES_BUCKET`, `SPACES_REGION`, `SPACES_ENDPOINT`. systemd injects them via `EnvironmentFile`; a manual run sources the same file.
50
- - **Dependency:** `python3` + the `boto3` module on the droplet. The script fails loudly if either is missing.
49
+ - **Credentials** live at `/etc/example/spaces.env` (chmod 600, owned by `lars`), never in the repo (ADR 0022). Required vars: `SPACES_KEY`, `SPACES_SECRET`, `SPACES_BUCKET`, `SPACES_REGION`, `SPACES_ENDPOINT`, `GDS_OFFSITE_AGE_RECIPIENT`. systemd injects them via `EnvironmentFile`; a manual run sources the same file.
50
+ - **Dependency:** `age`, plus `python3` + the `boto3` module on the droplet. The script fails loudly if any is missing.
51
+ - **The uploaded object is encrypted** (`<db>-<date>.sql.gz.age`) and the retention count applies to those. Any legacy plaintext `.sql.gz` object still under the prefix is **deleted on the next run**, outside the retention count — see below.
52
+
53
+ #### Offsite dumps are encrypted (task [#1003372](https://cloudbongos.com/builders#/task/1003372), audit A1)
54
+
55
+ Until this task the weekly job uploaded the raw `pg_dump`. A dump is not merely "a backup": it is every secret the platform holds, in one file, on storage the platform does not own — OAuth credentials, the GitHub App private key, and (before `core_249`) every builder's live session bearer in plaintext. A breach of the Space, a mis-set bucket ACL or a leaked read-only S3 key was a total compromise with no trace in any log we keep.
56
+
57
+ The dump is now encrypted with [`age`](https://github.com/FiloSottile/age) **to a public key**, so the droplet can produce backups it cannot itself read. That asymmetry is the point: an attacker who owns the droplet already has the live database, but they do not also get the archive of every previous state, and they cannot decrypt what is already in the Space. **Keep the private key off the server.**
58
+
59
+ ```bash
60
+ # ONE-TIME, on a machine that is NOT the server:
61
+ age-keygen -o bongos-backup.key # store safe + OFFLINE — this is the only way back
62
+ # it prints: Public key: age1xxxxxxxx...
63
+ ```
64
+
65
+ Add the **public** key to `/etc/example/spaces.env` as `GDS_OFFSITE_AGE_RECIPIENT=age1...`.
66
+
67
+ > **Without a recipient the job refuses to run**, and does not fall back to a plaintext upload — that fallback is the vulnerability, and a silent one, since a backup that "worked" looks identical either way. A failing timer is visible in `systemctl status gds-db-offsite.timer`; an unencrypted dump in a bucket is visible to nobody until it matters. **If offsite backups stop after this deploy, this is why** — set the recipient.
68
+ >
69
+ > **The first successful run also PURGES every unencrypted `.sql.gz` already in the Space**, unconditionally and outside the retention count. Encrypting from here on does nothing about the copies already up there, and retaining four of them "for safety" would retain the finding. Local nightly dumps on the droplet are untouched, so this removes copies, never the last copy.
70
+
71
+ To restore an offsite object:
72
+
73
+ ```bash
74
+ age -d -i bongos-backup.key <db>-<date>.sql.gz.age | gunzip | psql <db>
75
+ ```
51
76
 
52
77
  > **History:** an earlier task ([#7](https://example.com/builders#/task/7)) shipped a `scripts/backup.sh` + `/etc/cron.d/otb-backup` daily-to-Spaces cron that was forgotten and never worked (empty credentials, no Space, no log). [#415](https://example.com/builders#/task/415) retired it and replaced it with the systemd unit above. If you find any trace of `otb-backup.cron`, `scripts/backup.sh`, or `~/.config/otb/spaces.env` on a host, it's dead — remove it.
53
78
  >
@@ -65,6 +90,9 @@ SPACES_SECRET=...
65
90
  SPACES_BUCKET=otb-gds-backups
66
91
  SPACES_REGION=nyc3
67
92
  SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com
93
+ # The age PUBLIC key from `age-keygen`, generated OFF this box (see above).
94
+ # Without it the job refuses to run rather than upload plaintext.
95
+ GDS_OFFSITE_AGE_RECIPIENT=age1...
68
96
  ENV
69
97
  sudo chmod 600 /etc/example/spaces.env
70
98
 
@@ -0,0 +1,103 @@
1
+ -- core_249 — builder_sessions stores a HASH of the bearer, never the bearer
2
+ -- (task 1003372, audit ref A1 from the 2026-08-29 security audit, goal 1000084).
3
+ --
4
+ -- forward-unsafe: drops builder_sessions.token and makes token_hash NOT NULL.
5
+ -- Both are the point of the migration, not incidental to it. The whole finding is
6
+ -- that the plaintext bearer is READABLE AT REST, so a version of this that kept the
7
+ -- column would not fix anything; and the NOT NULL is what stops a future writer
8
+ -- inserting a session no lookup can ever match. Neither is reversible, and neither
9
+ -- should be: rolling back to code that reads `token` finds it gone, but a variant
10
+ -- that merely emptied the column would leave that code finding NULL and signing
11
+ -- everyone out just the same — so the unsafety is in the rollback direction only,
12
+ -- and is identical either way. Forward, nobody is signed out: the backfill below
13
+ -- derives every existing row's hash from the plaintext before the column goes.
14
+ --
15
+ -- WHAT WAS WRONG. `token` held the live bearer in plaintext, NOT NULL UNIQUE and
16
+ -- INDEXED. Every row was a directly replayable credential for the builder it
17
+ -- belonged to — Archons included — so ANY read of this table was a complete
18
+ -- takeover of every account holding a session. That is the exact credential the
19
+ -- whole ADR 0016 trust boundary rests on: the server re-resolves authority per
20
+ -- request from a token, so a token IS the authority. `token_hash` had zero
21
+ -- occurrences repo-wide before this migration.
22
+ --
23
+ -- A read of the table is not hypothetical. The offsite dump was uploaded
24
+ -- unencrypted (same task, `scripts/gds/db-backup-offsite.sh`), and a co-tenant
25
+ -- Postgres role reached this table across tenants until task 1003369 split the
26
+ -- unix accounts. Hashing at rest is what makes those reads worthless rather than
27
+ -- catastrophic.
28
+ --
29
+ -- WHY A BACKFILL AND NOT A FORCED RE-AUTH. The hash is computable from the
30
+ -- plaintext we already hold, so every live session survives the change: the row
31
+ -- keeps working and its holder notices nothing. The alternative — ship the code
32
+ -- and let sessions re-mint — would sign out every builder and every dev box on
33
+ -- deploy day to reach a state this statement reaches for free. Migration 035 and
34
+ -- core_243 both reasoned this way in their own direction: do not put a policy
35
+ -- change on the backs of the people holding live sessions when it lands.
36
+ --
37
+ -- WHY sha256 WITH NO EXTENSION. Postgres 16 (docs/architecture.md) carries
38
+ -- `sha256(bytea)` as a built-in, so the backfill needs no pgcrypto — one less
39
+ -- extension to have installed on a box before a migration will run. It is the
40
+ -- same digest `db-kernel.js` computes in Node, so a row backfilled here and a row
41
+ -- minted by the code resolve identically.
42
+ --
43
+ -- WHY A FAST HASH IS THE RIGHT ONE. A session token is 256 bits of CSPRNG, not a
44
+ -- password: there is no dictionary to run and nothing to slow an attacker down,
45
+ -- so bcrypt/argon would buy nothing and cost a KDF on every authenticated
46
+ -- request. The same reasoning is written out at
47
+ -- `modules/platform-identity/platform-identity.js` (`sha256hex`), which hashes
48
+ -- its client secrets and authorization codes for exactly this reason.
49
+ --
50
+ -- WHY THE PLAINTEXT COLUMN IS DROPPED, NOT NULLED. Nulling it would leave a dead
51
+ -- column that still has to be kept empty by every future writer, and it buys no
52
+ -- rollback safety: rolling back to code that reads `token` finds it NULL and
53
+ -- signs everyone out either way. Dropping it is the honest end state, and the
54
+ -- unique index moves to `token_hash` so the uniqueness guarantee is unbroken.
55
+ --
56
+ -- WHAT THIS DOES NOT CHANGE. Expiry is untouched. ADR 0294 (CLI sessions do not
57
+ -- expire) and ADR 0206 (no absolute ceiling) both stand exactly as written; this
58
+ -- migration does not read, write or reason about `expires_at`. The audit also
59
+ -- asked for refresh-on-use to stop and an absolute lifetime to be added — both are
60
+ -- decisions those two ADRs have already taken the other way, with the owner as
61
+ -- decider, AFTER this audit was filed. Changing them is not this task's to do.
62
+ --
63
+ -- IDEMPOTENT. The whole body is guarded on the plaintext column still existing,
64
+ -- so a second run is a no-op rather than an error about a column that is gone.
65
+
66
+ BEGIN;
67
+
68
+ DO $$
69
+ BEGIN
70
+ IF EXISTS (
71
+ SELECT 1 FROM information_schema.columns
72
+ WHERE table_name = 'builder_sessions' AND column_name = 'token'
73
+ ) THEN
74
+
75
+ ALTER TABLE builder_sessions ADD COLUMN IF NOT EXISTS token_hash text;
76
+
77
+ -- Every live row keeps working: the hash is derived from the bearer already
78
+ -- stored, so no holder is signed out by this deploy.
79
+ UPDATE builder_sessions
80
+ SET token_hash = encode(sha256(token::bytea), 'hex')
81
+ WHERE token_hash IS NULL;
82
+
83
+ -- The invariant the lookup depends on. Enforced only after the backfill has
84
+ -- given every existing row a value.
85
+ ALTER TABLE builder_sessions ALTER COLUMN token_hash SET NOT NULL;
86
+
87
+ -- Carries over `token`'s UNIQUE: two sessions may not share a bearer, and the
88
+ -- hash is what the lookup now keys on, so it needs the same index the
89
+ -- plaintext had for the same reason.
90
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_builder_sessions_token_hash
91
+ ON builder_sessions (token_hash);
92
+
93
+ DROP INDEX IF EXISTS idx_builder_sessions_token;
94
+ ALTER TABLE builder_sessions DROP COLUMN token;
95
+
96
+ END IF;
97
+ END
98
+ $$;
99
+
100
+ COMMENT ON COLUMN builder_sessions.token_hash IS
101
+ 'SHA-256 hex of the bearer token. The plaintext is NEVER stored (core_249, audit A1): it exists only in the response that mints it and in the client that holds it. lookupSession hashes the presented bearer and keys on this column, so a read of this table yields nothing replayable.';
102
+
103
+ COMMIT;
@@ -48,8 +48,31 @@
48
48
  const EVENT_CATALOG = Object.freeze(['task.shipped', 'idea.graded.full']);
49
49
 
50
50
  // Mirrors agents_definitions_trigger_type_chk. 'event' consumes the catalog above;
51
- // 'on-demand' is the historian shape (a builder asks, the agent answers).
52
- const TRIGGER_TYPES = Object.freeze(['event', 'on-demand']);
51
+ // 'on-demand' is the historian shape (a builder asks, the agent answers); 'cron'
52
+ // fires on a cadence and consumes CADENCES below (task 1004008, agents_005).
53
+ const TRIGGER_TYPES = Object.freeze(['event', 'on-demand', 'cron']);
54
+
55
+ // The cadence vocabulary a 'cron' trigger may declare, in trigger_spec.cadence.
56
+ //
57
+ // DUPLICATED FROM scripts/gds/routine-schedule.js ON PURPOSE, for the reason
58
+ // RANK_ORDER below is duplicated: a module may require only src/module-api.js
59
+ // (ADR 0083), so it cannot reach into scripts/. The two copies are kept honest by
60
+ // tests/agents_cron_trigger.mjs, which reads routine-schedule.js's own CADENCES +
61
+ // isUniformDivisor and asserts this file agrees — a drifting copy fails there
62
+ // rather than at some future tick.
63
+ //
64
+ // N MUST DIVIDE 60 for the sub-hourly form. A cron step restarts each hour, so
65
+ // only a divisor yields a uniform period: `*/15` fires :00 :15 :30 :45 always,
66
+ // but `*/7` fires :00…:56 then waits 4 — a "7min" cadence that is not one.
67
+ const CADENCES = Object.freeze(['hourly', 'daily', 'weekly', 'monthly']);
68
+ const SUBHOURLY_RE = /^([1-9]\d*)min$/;
69
+ function isCadence(name) {
70
+ if (CADENCES.includes(name)) return true;
71
+ const m = SUBHOURLY_RE.exec(String(name == null ? '' : name));
72
+ if (!m) return false;
73
+ const n = Number(m[1]);
74
+ return Number.isInteger(n) && n > 0 && n < 60 && 60 % n === 0;
75
+ }
53
76
 
54
77
  // Mirrors agents_definitions_model_tier_chk. No 'main' tier by design — an agent
55
78
  // is a subagent, never the main session.
@@ -222,6 +245,29 @@ function validateAgentDefinition(obj, opts = {}) {
222
245
  }
223
246
  }
224
247
 
248
+ // --- the cadence, fail-closed, the same shape as the event catalog above ----
249
+ // agents_005's CHECK pins that a cron definition HAS a non-empty cadence; the
250
+ // vocabulary is pinned here, because the sub-hourly rule is a predicate and a
251
+ // CHECK spelling it out would be a second copy free to drift.
252
+ //
253
+ // The reach is deliberately the EVENT BLOCK'S reach, no more: an UNWIRED
254
+ // cadence is refused wherever it appears, including on a non-cron definition
255
+ // where it is dead config. A VALID cadence on a non-cron definition is ACCEPTED,
256
+ // exactly as a wired `event:` on an on-demand definition is — harmless, unread,
257
+ // and not this validator's call to police. Saying otherwise in a comment would
258
+ // describe a guard that is not here.
259
+ if (spec) {
260
+ const cadence = safeGet(spec, 'cadence');
261
+ const hasCadenceKey = cadence !== undefined && cadence !== null;
262
+ if (triggerType === 'cron' && !isNonEmptyString(cadence)) {
263
+ E('trigger_cadence_missing', 'trigger_spec.cadence',
264
+ `trigger_spec.cadence: required for a 'cron' trigger — a definition with no cadence can never fire. Named cadences: ${CADENCES.join(', ')}; or a sub-hourly '<N>min' where N divides 60.`);
265
+ } else if (hasCadenceKey && !isCadence(cadence)) {
266
+ E('trigger_cadence_unwired', 'trigger_spec.cadence',
267
+ `trigger_spec.cadence: ${JSON.stringify(cadence)} is not a cadence. Named cadences: ${CADENCES.join(', ')}; or a sub-hourly '<N>min' where N divides 60 (so the declared period and the realized one are the same claim).`);
268
+ }
269
+ }
270
+
225
271
  // --- model tier -----------------------------------------------------------
226
272
  const rawTier = safeGet(obj, 'model_tier');
227
273
  const modelTier = rawTier === undefined || rawTier === null ? 'default' : rawTier;
@@ -355,6 +401,7 @@ function validateAgentDefinition(obj, opts = {}) {
355
401
  module.exports = {
356
402
  validateAgentDefinition,
357
403
  EVENT_CATALOG,
404
+ CADENCES, isCadence,
358
405
  TRIGGER_TYPES,
359
406
  MODEL_TIERS,
360
407
  RANK_ORDER,
@@ -0,0 +1,78 @@
1
+ -- agents_005_cron_trigger.sql — make 'cron' a trigger type that can exist (task 1004008).
2
+ --
3
+ -- Tasks 1002498 (the cron-router poller) and 1002501 (the cron roster) both ask
4
+ -- for a timer whose each tick SELECTS DUE CRON AGENTS. There are none and there
5
+ -- can be none: agents_001 pinned trigger_type to ('event','on-demand') on BOTH
6
+ -- agents_definitions and agents_runs, and lib/validate.js mirrors it. Build the
7
+ -- poller against that and you get a timer that is correct, tested, and selects
8
+ -- from an empty set by construction — green tests over an unreachable branch,
9
+ -- which is a false claim of coverage rather than coverage. This migration is the
10
+ -- enabling half; the poller is 1002498's and is deliberately NOT built here.
11
+ --
12
+ -- WIDENING A FAIL-CLOSED ENUM IS THE POINT, NOT A WORKAROUND. agents_001's header
13
+ -- calls these enums fail-closed on purpose: "an unwired trigger name is refused,
14
+ -- never silently stored". That rule is kept — 'cron' stops being unwired in the
15
+ -- same change that makes it storable, because lib/validate.js is widened and
16
+ -- constrained alongside. What is still true after this: a trigger name outside
17
+ -- the three is refused at both the validator and the constraint.
18
+ --
19
+ -- BOTH TABLES, AND WHY THE LEDGER MATTERS AS MUCH AS THE DEFINITION. agents_001
20
+ -- constrains trigger_type on agents_runs too. Widen only the definition and a
21
+ -- cron agent could be stored and enabled, and then fail at the moment it fires,
22
+ -- when the ledger insert violates its own CHECK — the worst shape available: a
23
+ -- definition that looks armed and cannot record a single run.
24
+ --
25
+ -- A CRON DEFINITION MUST SAY WHEN. agents_001 already refuses an event definition
26
+ -- with no event name ("a definition that can never fire"). A cron definition with
27
+ -- no cadence is the same defect wearing a different key, so it gets the same
28
+ -- treatment rather than being left for the poller to interpret at tick time.
29
+ -- The cadence VOCABULARY is validated in lib/validate.js, not here: the named set
30
+ -- ('hourly','daily','weekly','monthly') plus the open-ended sub-hourly form
31
+ -- ('15min') is a predicate, and a CHECK that tried to spell the sub-hourly rule —
32
+ -- N must divide 60 — would be a second, drifting copy of it. The constraint
33
+ -- pins the SHAPE (a non-empty cadence key is present); the validator pins the
34
+ -- VALUE. That is the same division agents_001 draws for `event`.
35
+ --
36
+ -- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
37
+ -- object is agents_-prefixed, there is no down-migration, and the filename is
38
+ -- agents_-prefixed (ADR 0108 §5). Idempotent: safe to re-run. No existing row
39
+ -- changes — widening a CHECK can only make rows MORE legal, so nothing that
40
+ -- validates today stops validating.
41
+
42
+ BEGIN;
43
+
44
+ DO $$
45
+ BEGIN
46
+ -- agents_definitions: the three WIRED trigger types.
47
+ IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_definitions_trigger_type_chk') THEN
48
+ ALTER TABLE agents_definitions DROP CONSTRAINT agents_definitions_trigger_type_chk;
49
+ END IF;
50
+ ALTER TABLE agents_definitions ADD CONSTRAINT agents_definitions_trigger_type_chk
51
+ CHECK (trigger_type IN ('event', 'on-demand', 'cron'));
52
+
53
+ -- agents_runs: the same three, so a cron fire can be RECORDED and not only run.
54
+ IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_runs_trigger_type_chk') THEN
55
+ ALTER TABLE agents_runs DROP CONSTRAINT agents_runs_trigger_type_chk;
56
+ END IF;
57
+ ALTER TABLE agents_runs ADD CONSTRAINT agents_runs_trigger_type_chk
58
+ CHECK (trigger_type IN ('event', 'on-demand', 'cron'));
59
+
60
+ -- The twin of agents_definitions_trigger_spec_event_chk: a cron definition
61
+ -- carries a non-empty cadence, or it is a definition that can never fire.
62
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_definitions_trigger_spec_cadence_chk') THEN
63
+ ALTER TABLE agents_definitions ADD CONSTRAINT agents_definitions_trigger_spec_cadence_chk
64
+ CHECK (trigger_type <> 'cron'
65
+ OR (trigger_spec ? 'cadence' AND length(trigger_spec ->> 'cadence') > 0));
66
+ END IF;
67
+ END $$;
68
+
69
+ -- agents_001 indexes (trigger_type, trigger_spec->>'event') for the event
70
+ -- router's "who wants this event" read. The cron router's read is a different
71
+ -- question — "which cron agents are due" — and it is answered by scanning the
72
+ -- cron rows, which are few. Adding a cadence index now would be indexing for a
73
+ -- query no committed code makes; 1002498 adds one if its poller needs it.
74
+
75
+ COMMENT ON CONSTRAINT agents_definitions_trigger_type_chk ON agents_definitions IS
76
+ 'The WIRED trigger vocabulary (task 1004008 added cron). Mirrored by TRIGGER_TYPES in modules/agents/lib/validate.js — widen both together or the two disagree about what can exist.';
77
+
78
+ COMMIT;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.887",
3
+ "version": "1.19.889",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.887",
9
+ "version": "1.19.889",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.887",
3
+ "version": "1.19.889",
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",