You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(worker): persist trigger attribution on the run record (issue #54)
A forge run's record could join to the triggers.json entry that fired
it only by the flow name heuristic, which two triggers naming one flow
defeat. The receiver has computed matched: { index, type, ... } since
issue #49, and it survives on job.data.trigger all the way into
recordRun's argument; buildRecord simply never read it.
buildRecord now persists triggerIndex and triggerType as explicit
nullable literals beside the replica fields whose admissibility
argument they reuse: an integer and a fixed enum, nothing
attacker-chosen. The third matched key (label/phrase/action) stays out
on purpose, because a label that satisfied an any predicate is
collaborator-applied payload text. Cron records keep null for both:
their attribution is already exact via the repeat:<id>:<millis> jobId
join, which also reaches records written before these fields existed,
where a new field cannot.
Specs: INT-RUN-HISTORY-FILE-CONTRACT AMENDED (schema, Why, Acceptance:
index 0 persists as 0, never null; the matched label never persists).
INT-CONTAINER-JOB-INPUTS AMENDED (cross-reference only; matched stays
event.json-only inside the container). INT-WEBHOOK-PAYLOAD-SUBSET and
INT-OUTBOX-CONTRACT UNCHANGED, checked.
Suite run in the CI posture (REQUIRE gates, live Valkey): 2073 pass,
0 skipped.
Signed-off-by: Rob Boerman <robboerman@live.nl>
"bytes": <int> | null } | null } // null when the job had no session at all
@@ -1836,7 +1841,22 @@ validator rather than a second copy of it.
1836
1841
are **integers**: this record's PII-free-by-construction property rests on it holding no attacker-chosen
1837
1842
string, and a host-assigned index is not one. The **branch name they imply is deliberately absent**, for
1838
1843
the same reason `session` omits its key and branch. Without these two fields, two records on one target
1839
-
read as an accidental double-run rather than as the pair an operator asked for. The `tokens` field is **additive and nullable**
1844
+
read as an accidental double-run rather than as the pair an operator asked for. The trigger-attribution
1845
+
fields — `triggerIndex`, `triggerType` — are **additive and nullable on the replica fields' precedent**,
1846
+
explicit literals read from the job's own `data.trigger.matched` by the same no-spread `buildRecord`.
1847
+
They persist the receiver's harness-computed decision record (`INT-CONTAINER-JOB-INPUTS`):
1848
+
`triggerIndex` is the raw `triggers.json` array position of the entry that fired (cron entries counted —
1849
+
the file index is the rule's identity), `triggerType` that entry's `on.type`. An **integer and a fixed
1850
+
enum are the same admissible class as `replica` and `session.reason`**; the third `matched` key
1851
+
(`label`/`phrase`/`action`) is **deliberately absent**, because a label that satisfied an `any`
1852
+
predicate is collaborator-applied payload text, and persisting it would put an attacker-adjacent string
1853
+
in a record whose PII-free property rests on holding none. Without these two fields a forge run joins to
1854
+
its trigger only by the flow-name heuristic, which two triggers naming one flow defeat — the exact
1855
+
ambiguity `matched` was minted to remove (issue #49; persisting it was deferred there by that issue's
1856
+
no-new-record-fields scope, not by this record's posture). **Cron records hold `null` for both on
1857
+
purpose**: a cron job's `data.trigger` is `{ id, pattern }` with no `matched`, and its attribution is
1858
+
already exact via the `repeat:<id>:<millis>` jobId join (above) — a join that also reaches records
1859
+
written before these fields existed, where a new field cannot. The `tokens` field is **additive and nullable**
1840
1860
in exactly the same way — an explicit no-spread literal of the runner's per-job usage totals
1841
1861
(`REQ-TOKEN-ACCOUNTING-AND-CAPS`), or `null` when the container died before emitting the runner `exit`
1842
1862
line. It is PII-free by construction: integer token counts and a numeric cost only, no
@@ -1894,7 +1914,10 @@ validator rather than a second copy of it.
1894
1914
charset-validated ids and its rows sum to `tokens.total`; given a usage block violating any field rule,
1895
1915
the record stores `usage: null`, never a partial; given a catch-path or pre-exit-line death,
1896
1916
`provider`/`model` still carry the host-effective dispatch values; given a fallback-metered
1897
-
(`metered: false`) or pre-ledger run, `usage` is `null` and no reader treats that as an error.
1917
+
(`metered: false`) or pre-ledger run, `usage` is `null` and no reader treats that as an error. Given a
1918
+
forge job whose data carries `trigger.matched`, the record holds `triggerIndex` and `triggerType` —
1919
+
index `0` persists as `0`, never as `null` — and neither field ever carries the matched
1920
+
`label`/`phrase`/`action`; given a cron, chained, or manual job, both are `null`.
1898
1921
1899
1922
## INT-OUTBOX-CONTRACT
1900
1923
@@ -2223,6 +2246,7 @@ recorded repair is re-running `/dispatch setup` (or editing the pointer by hand)
2223
2246
2224
2247
| Date | Change |
2225
2248
|---|---|
2249
+
| 2026-08-11 | Issue #54 (Gap 2: a forge run's record could not be attributed to the `triggers.json` entry that fired it). **INT-RUN-HISTORY-FILE-CONTRACT AMENDED**: two additive, nullable fields on the replica fields' precedent — `triggerIndex` (the raw triggers-array index of the winning entry, cron entries counted) and `triggerType` (that entry's `on.type`) — explicit literals read from the job's own `data.trigger.matched` by the same no-spread `buildRecord`. An integer and a fixed enum, the admissible class the record already holds; the third `matched` key (`label`/`phrase`/`action`) is deliberately NOT persisted, because a label that satisfied an `any` predicate is collaborator-applied payload text and this record's PII-free property rests on holding no attacker-chosen string. Cron records keep `null` for both on purpose: a cron job's attribution is already exact via the `repeat:<id>:<millis>` jobId join, which also reaches records written before these fields existed, where a new field cannot. Persisting `matched` was deferred by issue #49's own no-new-record-fields scope, not by this record's posture; issue #54 is the consumer that makes it earn its place. Acceptance pins index `0` persisting as `0`, never `null`. **INT-CONTAINER-JOB-INPUTS AMENDED**: one cross-reference — `matched` remains event.json-only inside the container and never enters the prompt; its `index`/`type` alone are now also persisted host-side. **INT-WEBHOOK-PAYLOAD-SUBSET UNCHANGED, checked**: `matched` is harness-computed metadata, not a payload field, so the subset is untouched. **INT-OUTBOX-CONTRACT UNCHANGED, checked**: chained children carry no `trigger` and record `null`/`null`, exactly as manual runs do. |
2226
2250
| 2026-08-09 | Follow-up audit after issue #60. **INT-SDK-SESSION-OPTIONS AMENDED**, a correction rather than an addition: the option block still showed the TWO-path `additionalSkillPaths` literal, while the prose beside it had already been updated to three protected roots. A contract block that disagrees with its own note is worse than either being wrong alone, since a reader checking the code against the spec would have found the spec confirming the old shape. Now shows repo, injected, overlay. No behaviour changed; the literal had been stale since the injected tier landed hours earlier. |
2227
2251
| 2026-08-09 | Issue #60 (Gap 3: `run.instructions`). **INT-TRIGGERS-FILE-CONTRACT AMENDED**: a new optional field on the three webhook types, refused on cron with a message naming `run.task`, capped at 2000 characters and refused rather than truncated. Surrounding whitespace is deliberately NOT refused here, unlike `run.image`, and the divergence is recorded: that rule exists because whitespace changes what an image REFERENCE means, and it does not change what prose means. **INT-CONTAINER-JOB-INPUTS AMENDED**: `prompt.md` may now carry an operator standing-instruction block in the envelope above the data region; it reaches no other file. **INT-WEBHOOK-PAYLOAD-SUBSET UNCHANGED, checked**: the field is operator config and is not a webhook body field, so the subset is untouched and the value never appears in `event.json`. |
2228
2252
| 2026-08-09 | Issue #60 (Gap 2: `run.skillsDir`, a per-trigger operator skills directory). **INT-TRIGGERS-FILE-CONTRACT AMENDED**: a new optional field on all four run kinds, with the validation SPLIT written out because both halves are load-bearing. Existence is not checked in the shared validator because BOTH services parse this file and the receiver may run on another host (the `run.folder` precedent); absoluteness is not checked there either, and that one is subtler, because `path.isAbsolute` is OS-dependent, so a shared check would let a Windows worker and a Linux receiver disagree about the same reviewed file. The worker enforces both where the answer is knowable: at boot for cron, and pre-spend per job for every kind. Also records that the value never reaches `/job/event.json` (it rides at JOB level, never inside `trigger`, which is what the subset is built from), and that injected skills are trigger-reachable and never AI-reachable. **INT-CONTAINER-JOB-INPUTS AMENDED**: `/job/trigger-skills/<name>/**` joins the layout as the one `/job` input that does NOT come from git, with the asymmetry argued rather than left to be noticed -- `.pi/` is read by oid because the serviced repo is only maintainer-trusted and an attacker can shape that tree, while `run.skillsDir` is operator-authored deploy-time config named in a reviewed file, so what remains is the ordinary filesystem hazard and the copier answers it the same way (lstat never stat, regular files only, destinations rebuilt from validated segments, bounded). It arrives on the EXISTING `/job:ro` bind. **INT-RUN-HISTORY-FILE-CONTRACT AMENDED**: six `skills-dir-*` reasons. **INT-SDK-SESSION-OPTIONS AMENDED**: the protected-root list goes to three, `/job/pi/skills` then `/job/trigger-skills` then `/opt/pi-global/skills`, consulted in that order. **INT-CONTAINER-RUNTIME-CONTRACT UNCHANGED, checked** -- and this is the entry the change was designed around: no mount is added, no flag, no env var, so a job with an injected skills dir has a docker argv byte-identical to one without, pinned by a test. |
0 commit comments