ICSAC commited on
Commit
76d2fc2
Β·
1 Parent(s): ce13e62

Audit 2026-09-28 (evening): a late author response is never overwritten by the window check, an accept waits for a panel report, a review of a paper that studies models passes the redaction gate, a data sentence does not take back a code claim, the stats snapshot never shrinks

Browse files
code_availability.py CHANGED
@@ -38,6 +38,9 @@ _CLAIM_PATTERNS = [
38
  r"\b(?:archive|repository)\s+(?:contains|includes|provides)\b",
39
  ]
40
  _CLAIM_RE = re.compile("|".join(f"(?:{p})" for p in _CLAIM_PATTERNS), re.I)
 
 
 
41
 
42
  # Said right after an availability heading, these mean there is nothing to link.
43
  _NOTHING_TO_SHARE = re.compile(
@@ -81,7 +84,14 @@ def find_claims(text: str) -> list[str]:
81
  """Availability statements in `text`, one quoted sentence each."""
82
  out: list[str] = []
83
  for m in _CLAIM_RE.finditer(text):
84
- if _NOTHING_TO_SHARE.search(text[m.end(): m.end() + 120]):
 
 
 
 
 
 
 
85
  continue
86
  quote = _sentence(text, m.start(), m.end())
87
  if quote not in out:
 
38
  r"\b(?:archive|repository)\s+(?:contains|includes|provides)\b",
39
  ]
40
  _CLAIM_RE = re.compile("|".join(f"(?:{p})" for p in _CLAIM_PATTERNS), re.I)
41
+ # The first pattern is a heading ("Data availability"); its answer follows it.
42
+ _HEADING_RE = re.compile(_CLAIM_PATTERNS[0], re.I)
43
+ _SENTENCE_END = re.compile(r"[.!?](?:\s|$)")
44
 
45
  # Said right after an availability heading, these mean there is nothing to link.
46
  _NOTHING_TO_SHARE = re.compile(
 
84
  """Availability statements in `text`, one quoted sentence each."""
85
  out: list[str] = []
86
  for m in _CLAIM_RE.finditer(text):
87
+ after = text[m.end(): m.end() + 120]
88
+ if not _HEADING_RE.fullmatch(m.group(0)):
89
+ # "Code is available. No new data were generated." The second
90
+ # sentence is about data; it does not take back the code claim.
91
+ end = _SENTENCE_END.search(after)
92
+ if end:
93
+ after = after[: end.start()]
94
+ if _NOTHING_TO_SHARE.search(after):
95
  continue
96
  quote = _sentence(text, m.start(), m.end())
97
  if quote not in out:
crossref_deposit.py CHANGED
@@ -905,12 +905,24 @@ def _push_publications(sub_dir: Path, submission: dict, doi: str, *, log) -> dic
905
  f"the landing page will show none")
906
  # The public stats snapshot rode only the legacy Zenodo path and had stood
907
  # still since May (audit 2026-09-28 pass D); refresh it with every publication.
 
 
 
908
  try:
909
  import stats as _stats
910
- stats_path = _stats.write_stats(config.REVIEWS_DIR,
911
- os.path.join(publications.WEBSITE_REPO, "src", "data", "stats.json"))
912
- if stats_path:
913
- extra.append(stats_path)
 
 
 
 
 
 
 
 
 
914
  except Exception as exc: # the stats page must never block a registration
915
  log(f" crossref: stats snapshot not refreshed ({exc})")
916
  publications.commit_and_push(message=f"publications: {entry['title']} ({entry['slug']}) β€” {doi}",
 
905
  f"the landing page will show none")
906
  # The public stats snapshot rode only the legacy Zenodo path and had stood
907
  # still since May (audit 2026-09-28 pass D); refresh it with every publication.
908
+ # The review files behind the May snapshot left reviews/ at the public flip,
909
+ # so a fresh count can be LOWER than the published one; a snapshot that
910
+ # would shrink the total is never written (audit 2026-09-28, evening).
911
  try:
912
  import stats as _stats
913
+ out = os.path.join(publications.WEBSITE_REPO, "src", "data", "stats.json")
914
+ fresh = _stats.compute_stats(config.REVIEWS_DIR)
915
+ try:
916
+ published = json.loads(Path(out).read_text()).get("total_reviewed") or 0
917
+ except Exception:
918
+ published = 0
919
+ if (fresh.get("total_reviewed") or 0) < published:
920
+ log(f" crossref: stats snapshot kept: a fresh count of {fresh.get(total_reviewed)} "
921
+ f"would replace the published {published}")
922
+ else:
923
+ stats_path = _stats.write_stats(config.REVIEWS_DIR, out)
924
+ if stats_path:
925
+ extra.append(stats_path)
926
  except Exception as exc: # the stats page must never block a registration
927
  log(f" crossref: stats snapshot not refreshed ({exc})")
928
  publications.commit_and_push(message=f"publications: {entry['title']} ({entry['slug']}) β€” {doi}",
intake/apply_decision.py CHANGED
@@ -168,6 +168,14 @@ def main(argv: list[str]) -> int:
168
  code_link_claims = []
169
 
170
  panel_md, rqc_md = worker._scrubbed_report_pair(sub_id, title, tier=tier)
 
 
 
 
 
 
 
 
171
  deposit_doi = state_pre.get("deposit_doi")
172
  deposit_url = state_pre.get("deposit_url")
173
 
 
168
  code_link_claims = []
169
 
170
  panel_md, rqc_md = worker._scrubbed_report_pair(sub_id, title, tier=tier)
171
+ # The acceptance email attaches the panel report and the record publishes it:
172
+ # an accept with none (the panel never ran, e.g. right after a code-check
173
+ # override, or the redaction gate dropped it) waits (audit 2026-09-28).
174
+ if verdict == "accept" and not panel_md.strip() and not force:
175
+ print(f"{sub_id} has no publishable panel report (not reviewed yet, or the redaction "
176
+ f"gate dropped it: see the curator alert). Nothing done. Decide after the panel, "
177
+ f"or run with ICSAC_DECISION_FORCE=1 to accept without the report.", file=sys.stderr)
178
+ return 3
179
  deposit_doi = state_pre.get("deposit_doi")
180
  deposit_url = state_pre.get("deposit_url")
181
 
intake/author_approval.py CHANGED
@@ -23,7 +23,10 @@ author_hold_note.
23
  """
24
  from __future__ import annotations
25
 
 
26
  import datetime as _dt
 
 
27
  import hashlib
28
  import json
29
  import re
@@ -107,7 +110,24 @@ def _load(sub_dir: Path) -> Optional[dict]:
107
 
108
 
109
  def _save(sub_dir: Path, rec: dict) -> None:
110
- (Path(sub_dir) / FILE).write_text(json.dumps(rec, indent=2) + "\n")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
 
113
  def is_test(sub_id: str) -> bool:
@@ -227,31 +247,39 @@ def record(sub_dir: Path, rec: dict, choice: str, note: str = "",
227
  raise ValueError(f"unknown exclusion(s): {bad}")
228
  if choice != "approve" and excl:
229
  excl = [] # exclusions only mean something on an approval
230
- st = _state(sub_dir)
231
- if st.get("crossref_registered_at"):
232
- raise Locked("the DOI is already registered; write to help@icsacinstitute.org for a correction")
233
- if rec.get("status", "pending") != "pending" or rec.get("responses"):
234
- # One response per paper (policy since 2026-09-28): the first click is the
235
- # instruction of record; anything after it goes through help@.
236
- raise Locked("your response is already on record; for any change write to "
237
- "help@icsacinstitute.org with your submission ID in the subject line")
238
- entry = {"at": _iso(_now()), "choice": choice, "exclusions": excl, "note": note,
239
- "quote_ok": bool(quote_ok) and choice == "approve",
240
- "orcid": (orcid or "").strip()} # the iD that signed this response (2026-09-28)
241
- rec.setdefault("responses", []).append(entry)
242
- rec["status"] = CHOICES[choice]
243
- _save(sub_dir, rec)
244
- fields = {
245
- "author_approval_status": rec["status"],
246
- "author_exclusions": excl,
247
- "promotion_opt_out": bool(set(excl) & PROMOTION_IDS),
248
- "persistence_opt_out": "persistence" in excl,
249
- "withdrawn_by_author": choice == "withdraw",
250
- "author_quote_ok": entry["quote_ok"],
251
- }
252
- if choice in ("hold", "withdraw"):
253
- fields["author_hold_note"] = note
254
- _update_state(sub_dir, **fields)
 
 
 
 
 
 
 
 
255
  if audit:
256
  try:
257
  audit({"sub_id": sub_id, "event": "author_response", "choice": choice,
@@ -324,9 +352,13 @@ def check_windows(*, now: Optional[_dt.datetime] = None) -> int:
324
  f"Accepted {str(st.get('completed_at', '?'))[:10]}; the author has not responded by "
325
  f"{rec['deadline'][:10]}. Your call: intake/register-doi.sh {sub_dir.name} --live"):
326
  continue
327
- rec["window_closed_pinged"] = True
328
- rec["window_closed_pinged_at"] = _iso(now)
329
- _save(sub_dir, rec)
 
 
 
 
330
  pinged += 1
331
  return pinged
332
 
 
23
  """
24
  from __future__ import annotations
25
 
26
+ import contextlib
27
  import datetime as _dt
28
+ import fcntl
29
+ import os
30
  import hashlib
31
  import json
32
  import re
 
110
 
111
 
112
  def _save(sub_dir: Path, rec: dict) -> None:
113
+ p = Path(sub_dir) / FILE
114
+ tmp = p.with_name(p.name + ".tmp")
115
+ tmp.write_text(json.dumps(rec, indent=2) + "\n")
116
+ os.replace(tmp, p)
117
+
118
+
119
+ @contextlib.contextmanager
120
+ def _locked(sub_dir: Path):
121
+ """One writer at a time per paper: the intake server records responses while
122
+ the batch tick marks closed windows, and a stale read in either would overwrite
123
+ the other's write (audit 2026-09-28)."""
124
+ fd = os.open(str(Path(sub_dir) / (FILE + ".lock")), os.O_CREAT | os.O_RDWR, 0o600)
125
+ try:
126
+ fcntl.flock(fd, fcntl.LOCK_EX)
127
+ yield
128
+ finally:
129
+ fcntl.flock(fd, fcntl.LOCK_UN)
130
+ os.close(fd)
131
 
132
 
133
  def is_test(sub_id: str) -> bool:
 
247
  raise ValueError(f"unknown exclusion(s): {bad}")
248
  if choice != "approve" and excl:
249
  excl = [] # exclusions only mean something on an approval
250
+ with _locked(sub_dir):
251
+ cur = _load(sub_dir)
252
+ if cur is not None:
253
+ if cur.get("token_sha256") != rec.get("token_sha256"):
254
+ raise Locked("this link is no longer valid; write to help@icsacinstitute.org "
255
+ "with your submission ID in the subject line")
256
+ rec.clear()
257
+ rec.update(cur)
258
+ st = _state(sub_dir)
259
+ if st.get("crossref_registered_at"):
260
+ raise Locked("the DOI is already registered; write to help@icsacinstitute.org for a correction")
261
+ if rec.get("status", "pending") != "pending" or rec.get("responses"):
262
+ # One response per paper (policy since 2026-09-28): the first click is the
263
+ # instruction of record; anything after it goes through help@.
264
+ raise Locked("your response is already on record; for any change write to "
265
+ "help@icsacinstitute.org with your submission ID in the subject line")
266
+ entry = {"at": _iso(_now()), "choice": choice, "exclusions": excl, "note": note,
267
+ "quote_ok": bool(quote_ok) and choice == "approve",
268
+ "orcid": (orcid or "").strip()} # the iD that signed this response (2026-09-28)
269
+ rec.setdefault("responses", []).append(entry)
270
+ rec["status"] = CHOICES[choice]
271
+ _save(sub_dir, rec)
272
+ fields = {
273
+ "author_approval_status": rec["status"],
274
+ "author_exclusions": excl,
275
+ "promotion_opt_out": bool(set(excl) & PROMOTION_IDS),
276
+ "persistence_opt_out": "persistence" in excl,
277
+ "withdrawn_by_author": choice == "withdraw",
278
+ "author_quote_ok": entry["quote_ok"],
279
+ }
280
+ if choice in ("hold", "withdraw"):
281
+ fields["author_hold_note"] = note
282
+ _update_state(sub_dir, **fields)
283
  if audit:
284
  try:
285
  audit({"sub_id": sub_id, "event": "author_response", "choice": choice,
 
352
  f"Accepted {str(st.get('completed_at', '?'))[:10]}; the author has not responded by "
353
  f"{rec['deadline'][:10]}. Your call: intake/register-doi.sh {sub_dir.name} --live"):
354
  continue
355
+ with _locked(sub_dir):
356
+ # Re-read: the author may have answered while the ping was in flight.
357
+ cur = _load(sub_dir)
358
+ if cur and cur.get("status") == "pending" and not cur.get("window_closed_pinged"):
359
+ cur["window_closed_pinged"] = True
360
+ cur["window_closed_pinged_at"] = _iso(now)
361
+ _save(sub_dir, cur)
362
  pinged += 1
363
  return pinged
364
 
intake/scripts/test_audit_20260928b.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Regression checks for the second audit of 2026-09-28 (the evening sweep).
3
+ Offline: no network, no mail, no pings; every write lands in one temporary
4
+ directory that is removed at the end, pass or fail. Run from the repo root:
5
+ .venv/bin/python intake/scripts/test_audit_20260928b.py
6
+
7
+ Covers: a late author response is never overwritten by the window check; a
8
+ second response still refuses; the code claim is not taken back by a data
9
+ sentence; the redaction gate passes a review of a paper that studies models and
10
+ still stops seat talk; accept waits for a panel report.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import shutil
16
+ import sys
17
+ import tempfile
18
+ from pathlib import Path
19
+
20
+ ROOT = Path(__file__).resolve().parents[2]
21
+ sys.path.insert(0, str(ROOT))
22
+
23
+ failures: list[str] = []
24
+
25
+
26
+ def check(cond: bool, name: str) -> None:
27
+ print((" ok " if cond else " FAIL ") + name)
28
+ if not cond:
29
+ failures.append(name)
30
+
31
+
32
+ tmp = Path(tempfile.mkdtemp(prefix="icsac-audit-b-"))
33
+ try:
34
+ # ── author approval: the window check and a late response ──────────────
35
+ from intake import author_approval as aa
36
+ aa.SUBMISSIONS_ROOT = tmp
37
+ sub = tmp / "ICSAC-SUB-00099"
38
+ sub.mkdir()
39
+ (sub / "state.json").write_text(json.dumps({"state": "completed", "decision": "accept",
40
+ "completed_at": "2026-09-01T00:00:00Z"}))
41
+ rec = {"token_sha256": "abc", "issued_at": "2026-09-01T00:00:00Z",
42
+ "deadline": "2026-09-08T00:00:00Z", "status": "pending", "responses": [],
43
+ "window_closed_pinged": False}
44
+ aa._save(sub, rec)
45
+ sent = []
46
+
47
+ def ping_while_author_answers(msg: str) -> bool:
48
+ # The author's late approval lands while the curator ping is in flight.
49
+ sent.append(msg)
50
+ if len(sent) == 1: # record() pings the curator too; answer only once
51
+ aa.record(sub, aa._load(sub), "approve", "", [], orcid="0000-0000-0000-0001")
52
+ return True
53
+
54
+ real_ping = aa._ping
55
+ aa._ping = ping_while_author_answers
56
+ try:
57
+ aa.check_windows()
58
+ finally:
59
+ aa._ping = real_ping
60
+ final = aa._load(sub)
61
+ check(final["status"] == "approved" and len(final["responses"]) == 1,
62
+ "a response recorded during the window ping survives the tick")
63
+ check(not final.get("window_closed_pinged"), "the tick does not mark an answered paper as closed in silence")
64
+ try:
65
+ aa.record(sub, aa._load(sub), "withdraw", "x" * 40, [], orcid="0000-0000-0000-0001")
66
+ check(False, "a second response is refused")
67
+ except aa.Locked:
68
+ check(True, "a second response is refused")
69
+ stale = dict(final, token_sha256="other")
70
+ (sub / aa.FILE).write_text(json.dumps(dict(final, status="pending", responses=[])))
71
+ try:
72
+ aa.record(sub, stale, "approve", "", [], orcid="0000-0000-0000-0001")
73
+ check(False, "a response through a re-issued link's old token is refused")
74
+ except aa.Locked:
75
+ check(True, "a response through a re-issued link's old token is refused")
76
+ check(not list(sub.glob("*.tmp")), "no temporary file is left beside the record")
77
+
78
+ # ── code claims ─────────────────────────────────────────────────────────
79
+ import code_availability as ca
80
+ check(len(ca.find_claims("The code is available on request. No new data were generated.")) == 1,
81
+ "a data sentence after a code claim does not take the claim back")
82
+ check(ca.find_claims("Data availability\nNot applicable.\n") == [],
83
+ "a heading answered 'Not applicable' is still no claim")
84
+ check(ca.find_claims("Data availability: no new data were generated in this study.") == [],
85
+ "a heading answered 'no new data' is still no claim")
86
+ check(len(ca.find_claims("Code availability: the scripts are available on request.")) >= 1,
87
+ "a heading followed by a real statement is a claim")
88
+
89
+ # ── redaction: models studied vs seats named ────────────────────────────
90
+ import redaction
91
+ try:
92
+ redaction.assert_clean("The authors compare the GPT model with two Llama models and a Gemini model.")
93
+ check(True, "a review of a paper that studies models passes the gate")
94
+ except redaction.RedactionLeak:
95
+ check(False, "a review of a paper that studies models passes the gate")
96
+ for leak in ("The Claude-position reviewers agree.", "the Qwen slot scored it lower", "a GPT reviewer"):
97
+ try:
98
+ redaction.assert_clean(leak)
99
+ check(False, f"seat talk is fatal: {leak!r}")
100
+ except redaction.RedactionLeak:
101
+ check(True, f"seat talk is fatal: {leak!r}")
102
+
103
+ # ── accept waits for a panel report ─────────────────────────────────────
104
+ src = (ROOT / "intake" / "apply_decision.py").read_text()
105
+ i = src.find("panel_md, rqc_md = worker._scrubbed_report_pair(")
106
+ j = src.find('if verdict == "accept" and not panel_md.strip() and not force:')
107
+ k = src.find("publications_url_str: str | None = None")
108
+ check(0 < i < j < k, "the no-report guard sits before the first side effect of an accept")
109
+
110
+ # ── the stats snapshot never shrinks ────────────────────────────────────
111
+ import stats
112
+ rdir = tmp / "reviews"; rdir.mkdir()
113
+ check(stats.compute_stats(str(rdir)).get("total_reviewed") == 0, "an empty reviews dir counts zero")
114
+ cd = (ROOT / "crossref_deposit.py").read_text()
115
+ g = cd.find("if (fresh.get(\"total_reviewed\") or 0) < published:")
116
+ w = cd.find("stats_path = _stats.write_stats(config.REVIEWS_DIR, out)")
117
+ check(0 < g < w, "a fresh count below the published total is never written")
118
+ finally:
119
+ shutil.rmtree(tmp, ignore_errors=True)
120
+
121
+ print(f"\n{'ALL GREEN' if not failures else f'{len(failures)} FAILURE(S)'}")
122
+ sys.exit(1 if failures else 0)
redaction.py CHANGED
@@ -92,9 +92,10 @@ SOFT_WARN_TOKENS: tuple[str, ...] = (
92
  # Panel-composition talk in reviewer or RQC prose: a vendor or model name tied
93
  # to a seat ("Claude-position reviewers", "the GPT slot"). A paper's own AI-use
94
  # disclosure never takes this shape, so it is fatal wherever it appears, prose
95
- # included (audit 2026-09-28 item 4).
 
96
  FORBIDDEN_PANEL_PATTERNS: tuple[str, ...] = (
97
- r"(?i)\b(?:claude|anthropic|gpt|chatgpt|openai|gemini|gemma|qwen|deepseek|llama|mistral|nemotron|sonnet|opus)[- ](?:position|seat|slot|reviewer|model)s?\b",
98
  )
99
 
100
  FORBIDDEN_EXFIL_PATTERNS: tuple[str, ...] = (
@@ -756,7 +757,7 @@ def build_public_rqc_markdown(parsed: ParsedRQC) -> str:
756
  enforces this before the redaction writes anything to the site.
757
  """
758
  status_line = (
759
- "Review Quality Control: flagged β€” reviewed by human curators before acceptance."
760
  if parsed.flag
761
  else "Review Quality Control: passed."
762
  )
 
92
  # Panel-composition talk in reviewer or RQC prose: a vendor or model name tied
93
  # to a seat ("Claude-position reviewers", "the GPT slot"). A paper's own AI-use
94
  # disclosure never takes this shape, so it is fatal wherever it appears, prose
95
+ # included (audit 2026-09-28 item 4). "Model" is not a seat word: a review of a
96
+ # paper that studies "the GPT model" or "Llama models" must not trip the gate.
97
  FORBIDDEN_PANEL_PATTERNS: tuple[str, ...] = (
98
+ r"(?i)\b(?:claude|anthropic|gpt|chatgpt|openai|gemini|gemma|qwen|deepseek|llama|mistral|nemotron|sonnet|opus)[- ](?:position|seat|slot|reviewer)s?\b",
99
  )
100
 
101
  FORBIDDEN_EXFIL_PATTERNS: tuple[str, ...] = (
 
757
  enforces this before the redaction writes anything to the site.
758
  """
759
  status_line = (
760
+ "Review Quality Control: flagged β€” reviewed by the curation team before acceptance."
761
  if parsed.flag
762
  else "Review Quality Control: passed."
763
  )