Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
855 changes: 608 additions & 247 deletions .github/workflows/supporter-loop-reusable.yml

Large diffs are not rendered by default.

78 changes: 75 additions & 3 deletions supporter-loop/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Supporter loop

The reusable workflow implements the frozen [Supporter Reward Loop contract](https://github.com/caty-ai/x-collector/blob/epic/119/docs/supporter-loop/CONTRACT.md) (moves to `main` when the epic merges), v1.11. The caller initially selects `record-only`; enabling live delivery and approving the placeholder Japanese/English comment templates belong to owner checkpoint #4. The optional backfill input is deliberately absent until that checkpoint.
The reusable workflow implements the frozen [Supporter Reward Loop contract](https://github.com/caty-ai/x-collector/blob/epic/119/docs/supporter-loop/CONTRACT.md) (moves to `main` when the epic merges), v2.0. The caller initially selects `record-only`; enabling live delivery and approving the placeholder Japanese/English comment templates belong to owner checkpoint #4. The retired `backfill` input is absent; historical `ok-backfill` lines remain successful deliveries.

- `check-contents-decode.sh <workflow.yml> <fixtures-dir>` requires CR/LF stripping at every base64 decode, byte-compares wrapped Contents API fixtures, and verifies that plain decoding fails.
- `check-ledger.sh <ndjson files...>` prints the number of mode/action violations and succeeds only at zero.
Expand All @@ -17,12 +17,84 @@ The reusable workflow implements the frozen [Supporter Reward Loop contract](htt
- Backslash-newline continuations inside a shell word join without an inserted space, preserving curl option clusters, methods and endpoint/path words. An odd trailing backslash count escapes the newline; an even count leaves literal backslashes and a real statement boundary. Shell single quotes do not enable continuation. Whitespace before the continuation backslash keeps the word separator. Unsupported multiline/folded YAML and escaped double-quoted run scalars still fail closed under rule `d`; use literal `|` blocks for executable multiline scripts.
- `reconcile-audit.sh --mode record-only|live` reads JSON state from stdin and reports divergence. Its header documents the fixture schema and `--live-gh` collection interface. Missing or incomplete evidence fails closed. Record-only checks the reduced audit; live checks access, strangers, and both marker directions, including the manual allowlists. The owner-only checkpoint #4 zero-send audit uses `checkpoint4: true` in the stdin JSON to compare the baseline invitation set too; with `--live-gh` this additionally requires the invitation credential and still checks that the ledger credential receives 403.

`supporter-loop-selftest.yml` runs these scripts against clean and deliberately violating fixtures on pull requests touching this directory or either supporter-loop workflow. Fixtures are offline: no real credentials or GitHub writes are needed. The reusable workflow executes inline trusted code and never checks out the triggering ref. Additional fixture tests extract its actual jq transition, audit and SUPPORTERS.md models, verify the frozen interface, and execute only `record-only` decision/sweep code with fail-closed local API doubles. The live workflow path is not executed by these tests.
`supporter-loop-selftest.yml` runs these scripts against clean and deliberately violating fixtures on pull requests touching this directory or either supporter-loop workflow. Fixtures are offline: no real credentials or GitHub writes are needed. The reusable workflow executes inline trusted code and never checks out the triggering ref. Additional fixture tests extract its actual jq transition, audit and SUPPORTERS.md models, verify the frozen interface, and execute decision/sweep code and extracted live helpers with fail-closed local API doubles. These tests never contact GitHub or use real credentials; they do not prove live API permissions.

The family roster is copied from `external-input-watch.yml`: **case-insensitive; identical token set**. The twin reference is in the new workflow only because issue #75 forbids changing existing workflows. Keep these copies synchronized in a separately authorized change.

Caller concurrency, caller secrets registration, baseline creation, Contributors wall and release notes belong to child #3/the owner. Baselines and manual allowlists live under `ledger/` in the reward repository; this change does not create them. Live API permissions, owner baselines, checkpoint approvals and actual zero-send evidence require owner verification outside fixture CI.

The append-only schema has no comment ID. Live collection cannot prove that an absent marker was removed by moderation rather than edited. For a still-readable thread it conservatively blocks cleanup until the owner records the accepted exception in `manual-markers-allowlist.json`; fixture audits also accept explicit `marker_deleted` evidence. Deleted/unreadable threads self-exempt. The Actions API 1,000-result cap also blocks cleanup conservatively, including when unrelated workflows fill the repository-wide listing.
The append-only schema has no comment ID. Live collection cannot prove that an absent marker was removed by moderation rather than edited. For a still-readable thread it conservatively blocks cleanup until the owner records the accepted exception in `manual-markers-allowlist.json`; fixture audits also accept explicit `marker_deleted` evidence. Deleted/unreadable threads self-exempt. The Actions API 1,000-result cap also blocks cleanup conservatively, using only the per-workflow supporter-loop listing; unrelated workflows do not consume that cap.

Regeneration prepends the reward repository’s `SUPPORTERS.header.md` (owned by child #1) and fails closed if that header is missing or malformed.

## Contract v2.0

Every live sweep repairs stranded tier-1 projections first, then invites undelivered
stargazers in page order (never-invited before re-armed). All current-generation
`excluded-*` skips count regardless of key; `deferred-quota` never excludes.
Catch-up requires tier 1 enabled. Delivery lines use `<repo>:1:<id>` even on a
sweep; closures and sweep skips use `<repo>:sweep:<id>` with tier 0. Two expired
invitation cycles yield one `excluded-unresponsive` sweep skip. The actor's next
tier event can still re-invite normally.

The rolling quota counts actual successful invitation PUTs (`ok`, `ok-backfill`,
`ok-catchup`) plus recent pending invitations not represented by such a line.
`already-1` does not count. The window is `(now - 24 h, now]`, and `Q = max(0,
45 - B)` is re-evaluated before every sweep PUT. Waiting is limited to three
minutes cumulatively, with five-minute age-out lookahead. Event runs use the
platform quota directly. Record-only predicts the same batch using ledger-only B
(the unledgered pending term needs Administration and is unavailable).

Catch-up stops taking work at six minutes elapsed or 150 attempted/reserved
writes; queue entries reserve their closing ledger writes immediately. Audit,
marker and revoke loops also honor the elapsed deadline. Rotation, unconditional
regeneration, reminders and state/summary still run. All act content writes are
paced at least one second apart. Regeneration retries GET sha → fresh ledger read
→ render → PUT after each CAS conflict. Queued append lines follow that PUT;
a failed projection records errors and keeps those actors in backlog. An expired
tier-1 invitation disappears from the projection until re-invited; tier-2/3
recognition stays.

`ledger/<source_owner>--<source_repo>.sweep-state.json` is a derived owner latch:

```json
{"last_sweep_ts":"2026-09-08T00:00:00Z","backlog":0,
"cancelled_runs":{"event":0,"schedule":0},
"capacity_pct":{"stars":0,"supporters_md":0,"invitations":0},
"alarm_state":{"stars_70":false,"supporters_md_70":false,
"invitations_backlog":false,"red_run":false,"deferred_quota":false}}
```

Capacity values are fractions (0.7 = 70%): stars / 40000, published bytes / 1048576,
and B / 45. The watermark includes successful scheduled and manual sweeps.
Cancelled counts are informational and never gate delivery. Live sweeps send one
summary with delivered/backlog/cancelled/capacity/revoked counts and alarm
transitions only; missing/corrupt state starts from false. Record-only never
reads or writes this latch and never sends Telegram.

**422 launch gate:** `INVITE_LIMIT_PATTERN` is defined once in the reusable
workflow and is provisional: `invit.*(limit|exceed|too many|24 ?h|per day)|too many invit`.
Precondition: the reward repository must contain a valid `SUPPORTERS.header.md`; missing or malformed headers fail regeneration and the live sweep closed.

Before merge, the owner must capture a real invitation-limit response from the
approved probe, remove sensitive data, replace
`fixtures/workflow/invitation-limit-422.json` with that body, and pin the pattern
to its exact `.message`/`.errors[].message`/`.errors[].code` wording. Rerun
`python3 -B supporter-loop/fixtures/workflow/test-live-contract.py`; the real
limit body must match while `sha does not match` and generic `Validation Failed`
422 bodies remain errors. Events defer the entire invitation/comment/projection
bundle, while sweeps write one deferred skip and retain a green backlog.

New v2 fixtures: `test-catchup-model.py` covers candidate ordering, namespaces,
quota window edges, historical values, mode parity and projection re-arm;
`test-live-contract.py` runs actual catch-up/event/regeneration/quota/state helpers
for 422 bundles, exclusions, queue ordering, CAS re-read, pacing, budget and
alarm transitions. `test-decide.py`, `test-model.py`, and `test-interface.py`
retain the existing execution/model/interface checks. The four `clean-*` ledger
fixtures cover the new results and tier-1 sweep deliveries; the vocabulary
checker already accepts these values, so its implementation needs no change.
The stars scanner reuses rule `a` for PAT/secret references, rule `b` for a second
step, noncanonical GET or write, and rule `d` for nonliteral run blocks. The three
`violating-*-stars-*.yml` fixtures pin those boundaries. The 400000-character
handoff guard is unchanged; stars now has a ten-minute timeout. A local jq
`stable_unique_by` definition preserves page order because jq's builtin sorts.
51 changes: 49 additions & 2 deletions supporter-loop/check-decide-static.sh
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,8 @@ function inspect(s,n,feeding,replacement, gh,curl,explicit,implicit,get,write,pa
if (unresolved || (write && facts["target_unknown"] &&
(!facts["method_seen"] || !facts["ledger_only"] || facts["non_ledger_method"]))) hit("b",n,"unresolvable gh/curl target in decide")
if (s ~ /(^|[^A-Za-z_])mutation([^A-Za-z_]|$)/) hit("b",n,"GraphQL mutation in decide")
if (write && (s ~ /\/(collaborators|invitations|comments|graphql)([^A-Za-z_-]|$)|\/contents\/SUPPORTERS[.]md/ || (gh && s ~ /api[ \t]+graphql([ \t]|$)/))) hit("b",n,"write to delivery endpoint")
if (facts["forbidden_host"]) hit("a",n,"forbidden Telegram host in resolved target")
if (write && (facts["delivery_endpoint"] || s ~ /\/(collaborators|invitations|comments|graphql)([^A-Za-z_-]|$)|\/contents\/SUPPORTERS[.]md/ || (gh && s ~ /api[ \t]+graphql([ \t]|$)/))) hit("b",n,"write to delivery endpoint")
if (write && facts["contents_bad"]) hit("a-prime",n,"Contents write outside provable ledger/ prefix")
if (write && match(s,/\/contents\//)) {
tail=substr(s,RSTART+RLENGTH)
Expand All @@ -255,6 +256,9 @@ function inspect(s,n,feeding,replacement, gh,curl,explicit,implicit,get,write,pa
# Inspect actual endpoint words, never a ledger-looking header or payload.
function target(t,facts, path,tail,literal) {
facts["target_seen"]=1
# request() joins adjacent quoted/unquoted shell fragments before this match.
if (t ~ /api[.]telegram[.]org/) facts["forbidden_host"]=1
if (t ~ /\/(collaborators|invitations|comments|graphql)([^A-Za-z_-]|$)|\/contents\/SUPPORTERS[.]md/ || t=="graphql") facts["delivery_endpoint"]=1
if (t=="" || t ~ /[{}]|[$][@*1-9]/ || (facts["replacement"]!="" && index(t,facts["replacement"]))) facts["target_placeholder"]=1
if (match(t,/\/contents\//)) {
tail=substr(t,RSTART+RLENGTH)
Expand All @@ -278,11 +282,27 @@ function target(t,facts, path,tail,literal) {
}
facts["ledger_only"]=0
}
# Decode one shell assignment word before inserting it into a quoted reference.
# Shell separators end the value only outside quotes; quoted spaces stay data.
function assignment_value(s, j,ch,q,escaped,w,offset) {
s=trim(s)
for (j=1;j<=length(s);j++) {
ch=substr(s,j,1)
if (escaped) { escaped=0; continue }
if (ch=="\\" && q!="\047") { escaped=1; continue }
if (ch=="\047" || ch=="\"") {
if (q=="") q=ch
else if (q==ch) q=""
} else if (q=="" && ch ~ /[ \t;|&]/) break
}
shell_words(substr(s,1,j-1),w,offset)
return w[1]
}
# Assignments are collected before any invocation is inspected. Conflicts and
# dynamic assignments permanently invalidate a name, independent of key order.
function collect(key,value,yaml, expression,token) {
if (yaml && trim(value) ~ /^"/ && index(value,"\\")) escaped_scalars[key]=1
value=unquote(value)
value=(yaml ? unquote(value) : assignment_value(value))
while (match(value,/\$\{\{[ \t]*(inputs|github|secrets|vars)[.][A-Za-z_0-9.]+[ \t]*\}\}/)) {
expression=substr(value,RSTART,RLENGTH)
if (!(expression in opaque)) opaque[expression]="OPAQUE_CONTEXT_" ++opaque_count
Expand Down Expand Up @@ -491,6 +511,32 @@ function nested_yaml(first,owner, j,t,d,root,seen) {
return seen
}

# v2 stars boundary: optional in legacy decide-only fixtures; when present it is
# a single secret-free GET step. Reuse a/b/d so the frozen selftest rule map holds.
/^ [A-Za-z_][A-Za-z_0-9-]*:/ {
in_stars=($0 ~ /^ stars:/)
if (in_stars) { stars_seen++; stars_line=NR }
}
in_stars {
star=trim($0)
if (star !~ /^#/) {
if (star ~ /secrets[.[]|SUPPORTER_.*TOKEN|TELEGRAM_/) hit("a",NR,"secret reference in stars")
if (star ~ /^- /) stars_steps++
if (star ~ /^run:/) {
stars_runs++
if (star !~ /^run: [|]$/) hit("d",NR,"stars requires a literal run block")
}
if (star ~ /(^|[^A-Za-z_])(curl|wget|ssh|nc|python[0-9]*|node|eval|bash|sh)([^A-Za-z_]|$)/ || star ~ /^uses:/)
hit("b",NR,"other executable or network call in stars")
if (star ~ /(^|[^A-Za-z_])gh[ \t]+api/) {
stars_gets++
if (star !~ /gh api --hostname github.com --method GET --paginate --slurp "repos\/[$]GITHUB_REPOSITORY\/stargazers[?]per_page=100" > "[$]work\/pages"/)
hit("b",NR,"stars must use the single canonical stargazer GET")
}
if (star ~ /--method (PUT|POST|PATCH|DELETE)|-X (PUT|POST|PATCH|DELETE)|star[+]json/)
hit("b",NR,"write or unsupported media type in stars")
}
}
# Workflow-level env is outside the decide slice but participates in shell
# expansion there. Collect only its mapping entries, including a name key.
/^env:[ \t]*(#.*)?$/ { workflow_env=1; next }
Expand Down Expand Up @@ -519,6 +565,7 @@ inside {
lines[++count]=$0; numbers[count]=NR
}
END {
if (stars_seen && (stars_seen!=1 || stars_steps!=1 || stars_runs!=1 || stars_gets!=1)) hit("b",stars_line,"stars must contain exactly one run step and one GET")
if (!found) { hit("structure",1,"decide job missing"); exit 1 }
command=""; start=0; quote=""
for (i=1;i<=count;i++) {
Expand Down
1 change: 1 addition & 0 deletions supporter-loop/fixtures/ledger/clean-deferred-quota.ndjson
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"schema": 1, "ts": "2026-09-08T00:00:00Z", "run_id": "91-1", "repo": "caty-ai/x-collector", "event": "sweep", "actor": "supporter", "actor_id": 42, "tier": 0, "subject": "", "action": "skip", "mode": "live", "result": "deferred-quota", "dedup_key": "caty-ai/x-collector:sweep:42", "gen": 1}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"schema": 1, "ts": "2026-09-08T00:00:00Z", "run_id": "91-1", "repo": "caty-ai/x-collector", "event": "sweep", "actor": "supporter", "actor_id": 42, "tier": 0, "subject": "", "action": "skip", "mode": "live", "result": "excluded-unresponsive", "dedup_key": "caty-ai/x-collector:sweep:42", "gen": 1}
1 change: 1 addition & 0 deletions supporter-loop/fixtures/ledger/clean-ok-catchup.ndjson
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"schema": 1, "ts": "2026-09-08T00:00:00Z", "run_id": "91-1", "repo": "caty-ai/x-collector", "event": "sweep", "actor": "supporter", "actor_id": 42, "tier": 1, "subject": "", "action": "invite", "mode": "live", "result": "ok-catchup", "dedup_key": "caty-ai/x-collector:1:42", "gen": 1}
1 change: 1 addition & 0 deletions supporter-loop/fixtures/ledger/clean-sweep-delivery.ndjson
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"schema": 1, "ts": "2026-09-08T00:00:00Z", "run_id": "91-1", "repo": "caty-ai/x-collector", "event": "sweep", "actor": "supporter", "actor_id": 42, "tier": 1, "subject": "", "action": "supporters-append", "mode": "live", "result": "ok", "dedup_key": "caty-ai/x-collector:1:42", "gen": 1}
33 changes: 33 additions & 0 deletions supporter-loop/fixtures/static/violating-a-stars-pat.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
jobs:
stars:
if: ${{ inputs.sweep == true }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
outputs:
ok: ${{ steps.list.outputs.ok }}
stargazers: ${{ steps.list.outputs.stargazers }}
steps:
- name: List stargazers of the source repo (ids only)
id: list
env:
GH_TOKEN: ${{ secrets.SUPPORTER_LEDGER_TOKEN }}
run: "set -euo pipefail\numask 077\nwork=\"$RUNNER_TEMP/stars\"\nmkdir -p \"\
$work\"\nok=true\n# \xA711(b): every page or nothing; any shape defect fails\
\ closed in decide.\nif ! gh api --hostname github.com --method GET --paginate\
\ --slurp \"repos/$GITHUB_REPOSITORY/stargazers?per_page=100\" > \"$work/pages\"\
\ \\\n || ! jq -e 'type==\"array\" and length>0 and all(.[]; type==\"array\"\
) and all(.[][]; .id|type==\"number\" and .>0 and floor==.)' \"$work/pages\"\
\ >/dev/null; then\n ok=false; printf '[]\\n' > \"$work/pages\"\nfi\n# jq\
\ builtin unique_by sorts: the local definition preserves first page occurrence.\n\
# IDs only, never logins or author text. Fail before GitHub's output-size\
\ truncation.\nstars=$(jq -c 'def unique_by(f): reduce .[] as $x ({seen:{},out:[]};\
\ ($x|f|tojson) as $k | if .seen[$k] then . else .seen[$k]=true | .out +=\
\ [$x] end) | .out; [.[][].id] | unique_by(.)' \"$work/pages\")\n[ \"${#stars}\"\
\ -lt 400000 ] || { echo '::error::stargazer handoff exceeds safe output size';\
\ exit 1; }\nprintf 'ok=%s\\n' \"$ok\" >> \"$GITHUB_OUTPUT\"\nprintf 'stargazers=%s\\\
n' \"$stars\" >> \"$GITHUB_OUTPUT\"\n"
decide:
steps:
- run: echo safe
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
jobs:
decide:
steps:
- run: |
u="https://api.github.com/repos/o/r/colla""borators/x"; curl -sf -X PUT "$u" -d "{}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
jobs:
decide:
steps:
- run: |
u='https://api.github.com/repos/o/r/colla''borators/x'; curl -sf -X PUT "$u" -d "{}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
jobs:
decide:
steps:
- run: |
u=https://api.github.com/repos/o/r/colla"borators/x"; curl -sf -X PUT "$u" -d "{}"
Loading
Loading