Status: Working draft. v1 scope spans Hourly Timesheet + Milestone Invoice; Reimbursement Claim deferred to Phase 5. Source issue: QuantEcon/admin#3 — PRJ: QuantEcon Timesheet Management System Related (broader vision, separate track): QuantEcon/admin#5 — PRJ: QuantEcon admin infrastructure
Phase progress — high-level summary. Detailed task lists per phase live in §8 Build phases.
- Phase 0 — Planning
- Phase 1 — Hourly Timesheet engine
- Phase 1.5 — Milestone Invoice engine
- Phase 3a — Reusable workflows (engine code centralised; contractor repos are thin callers)
- Phase 2 — Merge processing + email notify to PSL (engine code complete; partial E2E verified through ledger update + pinned-issue refresh; SMTP credentials needed to unlock the email step)
- 🛑 BREAK — testing phase (full E2E verified on
contractor-engine-test;testing_mode: trueconfirmed working) - Phase 2.5 — Revision + Independent-invoice handling (full E2E verified on
contractor-engine-test: revision via-vNwith supersede semantics, plus same-period-Bindependent invoices; both paths through ledger, PDF banner, email, cross-comments) - Phase 3b — Onboarding script for new contractor repos (E2E verified on a fresh
contractor-mmckyrepo: onboarding script → submit → PDF → ledger → email all worked first try) - Phase 3c — Deferred submission: issue-as-draft +
/submit+/validate+ period-close reminders (full E2E verified oncontractor-engine-test2026-05-19: six scenarios passed including the parse-fail → fix → re-submit arc and reminder idempotency) - Phase 4 — Docs + first real contractors (per-repo
testing_modeopt-in to production; see §9 "Email recipients & testing") ← current target - Phase 5 — Reimbursement Claim engine (post-launch)
- Goals
- Architectural decision — per-contractor private repos
- Repository topology
- Inside a contractor repo
- Onboarding script
- v1 scope
- Workflow in practice
- Build phases
- Resolved decisions
- Open items
- Security posture
- Working notes
Ship a GitHub-native system that lets QuantEcon contractors submit timesheets, have them reviewed via PR, and produce a clean PDF + GitHub notification on approval. Nothing more.
Constraints:
- Compensation data is sensitive — contractors must not see each others' rates, hours, or totals.
- QuantEcon does not host webapps; GitHub Pages + GitHub Actions is the only ops surface.
- Contractors are GitHub-familiar.
- Scale: 5–10 active contractors, monthly cadence.
- No third-party GitHub Actions on any financial-data path.
- Ship the timesheet loop first. Broader admin infrastructure (centralized contractor data, cross-contractor reporting, encryption-at-rest, contract lifecycle automation) is captured separately and is not in scope here.
A single shared repo with all contractors as collaborators would leak every contractor's rate, hours, and totals to every other contractor. Unacceptable for compensation data.
Selected: one private repo per contractor under the QuantEcon org, named QuantEcon/contractor-{github-handle}. Privacy by construction; preserves all GitHub-native benefits; at 5–10 contractors the onboarding overhead is a single scripted command.
Why contractor-{handle} and not timesheets-{handle}: the repo will later absorb other contractor-related artefacts (invoices, reimbursements, end-of-year statements, contract documents) without renaming. The system we're building is the first feature, not the only one.
Two repos:
QuantEcon/contractor-payments ← engine: workflows, scripts, Typst, contractor-template, onboarding script
QuantEcon/contractor-{handle} ← per-contractor private repo
End-state layout. Phase-status check-off lives in §8.
QuantEcon/contractor-payments/
├── .github/workflows/ ← reusable workflows for contractor repos (Phase 3)
│ ├── issue-to-pr.yml (workflow_call; called from contractor repos)
│ └── process-approved.yml (workflow_call; called from contractor repos)
├── scripts/ ← run in CI; checked out at workflow runtime
│ ├── __init__.py
│ ├── parse_issue.py (built — §4.3, §4.4)
│ ├── create_submission_pr.py (built — renders PDF + PNG, opens PR)
│ ├── post_error_comment.py (built — sentinel comment on parse fail)
│ ├── generate_pdf.py (built — PDF + PNG via Typst)
│ ├── update_ledger.py (Phase 2)
│ └── notify.py (Phase 2)
├── tests/
│ ├── __init__.py
│ ├── test_parse_issue.py (49 cases)
│ ├── test_create_submission_pr.py (24 cases)
│ └── test_post_error_comment.py (7 cases)
├── onboarding/
│ └── new-contractor.py ← interactive setup script (Phase 3, see §5)
├── templates/
│ ├── timesheet.typ (Typst single-page A4 template)
│ ├── fiscal-host.yml (PSL Foundation address; single source across repos)
│ └── assets/
│ ├── quantecon-logo.png
│ └── psl-foundation-logo.png
├── contractor-template/ ← files seeded into each new contractor repo
│ │ (onboarding script applies string.Template
│ │ substitution to every text file at copy time —
│ │ no `.template` suffix convention needed)
│ ├── .github/ISSUE_TEMPLATE/hourly-timesheet.yml (contains $CONTRACT_OPTIONS)
│ ├── .github/ISSUE_TEMPLATE/config.yml
│ ├── .github/workflows/issue-to-pr.yml (Phase 1: full inline workflow;
│ │ Phase 3 refactor: thin caller)
│ ├── .github/workflows/process-approved.yml (Phase 2 / Phase 3)
│ ├── .github/CODEOWNERS (contains $ADMIN)
│ ├── config/settings.yml (contains $CONTRACTOR_NAME etc.)
│ ├── contracts/.gitkeep
│ ├── submissions/.gitkeep
│ ├── ledger/.gitkeep
│ ├── generated_pdfs/.gitkeep
│ └── README.md (contractor-facing how-to)
├── docs/ ← MkDocs Material source; published to GitHub Pages
│ ├── index.md (placeholder landing — "guide coming soon")
│ └── contractor-guide/ (Phase 4 — submit-timesheet, submit-invoice, corrections)
├── notes/ ← internal dev/ops runbooks; NOT published
│ └── EMAIL_SETUP.md (SMTP setup runbook for §10 credentials)
├── mkdocs.yml (site config — Material theme, nav)
├── .github/workflows/docs.yml (build + deploy to Pages via artifact actions)
├── pyproject.toml (project metadata; deps: pyyaml + pytest)
├── .gitignore
└── PLAN.md (this file)
End-state layout (Phase 3 onwards). In Phase 1 the test repo also carries local copies of scripts/ and templates/; once Phase 3 lands reusable workflows, contractor repos hold only the thin caller workflows and reference the engine repo for scripts and templates.
QuantEcon/contractor-{handle}/
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── hourly-timesheet.yml (contract dropdown filtered to hourly contracts)
│ │ ├── milestone-invoice.yml (contract dropdown filtered to milestone contracts)
│ │ ├── reimbursement-claim.yml (Phase 5; see §8)
│ │ └── config.yml (blank issues disabled)
│ ├── workflows/
│ │ ├── issue-to-pr.yml (calls reusable from QuantEcon/contractor-payments)
│ │ └── process-approved.yml (calls reusable from QuantEcon/contractor-payments)
│ └── CODEOWNERS (auto-requests admin on every PR)
├── config/settings.yml (contractor identity, admin, payments manager handles, optional address)
├── contracts/<contract-id>.yml (admin-edited; see §4)
├── submissions/<YYYY-MM>/*.yml (auto-populated)
├── ledger/<contract-id>.yml (auto-populated on merge)
├── generated_pdfs/<YYYY-MM>/ (auto-populated)
│ ├── <id>.pdf (authoritative — sent to payments manager)
│ └── <id>.png (preview — embedded inline in PR body)
└── README.md
Phase 3 onboarding seeds both hourly-timesheet.yml and milestone-invoice.yml unconditionally. The reimbursement template lands in Phase 5 alongside the multi-select onboarding feature, which lets the admin opt repos in or out of any of the three template types (useful once reimbursement-only payees exist).
Access control:
- The contractor — Write (so they can push edits to their own submission PR branches).
- The admin (
mmckyinitially) — Admin. - The payments manager — Read (so they can see PDFs and get notifications).
Four kinds of files shape how a contractor repo works: identity/routing config (§4.1), contract terms (§4.2), the submission form (§4.3), and the validation behaviour wired around the form (§4.4). The first three are admin-authored; the contractor only interacts with the form itself.
contractor:
name: Jane Doe
github: janedoe
email: jane.doe@example.com
address: | # optional, multi-line
Research School of Economics
Australian National University
Canberra, ACT 2601
Australia
admin: mmckyWritten once by the onboarding script; rarely changes afterwards.
- Currency is not a global default — it lives on each contract (§4.2).
- Address is optional but recommended for tax-invoice compliance (Australian tax invoices over $1,000 AUD must identify the supplier; address is one accepted way). Renders on the PDF only when populated.
- Fiscal-host config doesn't live here. QuantEcon and PSL Foundation addresses, the document-date timezone, and the email notification recipients all live in the engine repo's
templates/fiscal-host.ymlas the single source of truth across every contractor repo (§9). Per-contractorsettings.ymlonly carries contractor identity. - The payments manager isn't a GitHub handle anymore. PSL receives approvals by email (§6, §8 Phase 2), so there's no per-contractor
payments_manager:field — the recipient is centralised infiscal-host.yml.notifications.psl_to.
The contract is the authorization for labor claims (hourly or milestone). Reimbursements are not tied to a contract — they're contractor-level and authorized per-claim via the approval flow (see §4.6).
Two contract types:
Hourly contract:
contract_id: QE-PSL-2026-001
type: hourly # hourly | milestone
status: active # active | ended
start_date: 2026-01-01
end_date: 2026-12-31
terms:
hourly_rate: 45.00
currency: AUD # ISO 4217 — AUD | USD | JPY supported in v1
max_hours_per_month: 40
project: CHOW # PSL funding/billing code (billed-against); shown as "Project" on PDFs
role: Research Assistant # descriptive role — contract metadata only
ledger_issue: 5 # GitHub issue # for the auto-updated ledger view (Phase 2)
notes: |
Continuing from 2025 contract.Milestone contract:
contract_id: QE-IUJ-2025-002
type: milestone
status: active
start_date: 2025-09-01
end_date: 2026-02-28
currency: JPY # default currency for milestone claims
project: CHOW # PSL funding/billing code (billed-against); shown as "Project" on PDFs
role: Visiting Researcher # descriptive role — contract metadata only
milestones: # pre-declared schedule; canonical source of truth
- id: 1
date: 2025-09-15
amount: 77000
description: Monthly Payment — September
- id: 2
date: 2025-10-15
amount: 77000
description: Monthly Payment — October
# ... (6 total in this example)
ledger_issue: 6 # GitHub issue # for the auto-updated ledger view (Phase 2)
notes: |
Optional free-text addendum — anything that doesn't fit the structured
milestones list above (e.g. amendments, side conditions, contact info).The ledger_issue field is written by Phase 3b's onboarding script when it opens the ledger issue; the approval workflow reads it to know which issue to edit. Optional — if missing, the workflow skips the issue update (the YAML side still gets the entry).
Structured milestones[] (Phase 3b, decided 2026-05-18). The contract pre-declares the payment schedule as structured data: each milestone has an id (the number the contractor cites at submission time), date, amount, and description. Onboarding collects these interactively. The parser cross-checks submitted milestone IDs against this list and emits a non-blocking warning (surfaced in the PR body alongside other parse warnings) when a submitted ID isn't in the contract — catches typos cheaply without rejecting otherwise-valid submissions. Engine remains permissive; the admin still has the final say during PR review.
Earlier versions of this spec deferred structured milestones to the broader admin infrastructure work (QuantEcon/admin#5). Phase 3b pulled it forward because onboarding collects the same data anyway — putting it in a structured field at write-time costs nothing and unlocks the parser warning + future cross-contract reporting.
Contract ID convention. QuantEcon uses QE-{PAYER}-YYYY-NNN:
QE— QuantEcon{PAYER}— paying entity (PSLfor PSL Foundation, others as needed)YYYY— contract yearNNN— sequential within year, zero-padded
The system doesn't enforce this format (it accepts any string), but the onboarding script will pre-fill it as the default when creating new contracts.
One file per contract. To renew, the admin copies an existing contract file, edits the dates and rate (or milestone schedule), gives it a new contract_id, and marks the old one ended.
Currency handling: each contract specifies its own currency. Supported ISO 4217 codes in v1: AUD, USD, JPY. The Typst template renders amounts with the ISO code as a suffix (e.g. 45.00 AUD, 30.00 USD, 77000 JPY) — clean and unambiguous, no symbol conventions. JPY is rendered without decimal places; AUD and USD use two. Other ISO codes can be added when a real contractor needs one.
Each contractor repo exposes three issue-template options on the "New Issue" page:
| Template | Filename | Filed against | What it claims |
|---|---|---|---|
| 📋 Hourly Timesheet | hourly-timesheet.yml |
Hourly contract | Hours worked in a month |
| 🎯 Milestone Invoice | milestone-invoice.yml |
Milestone contract | A specific milestone delivered |
| 🧾 Reimbursement Claim | reimbursement-claim.yml |
Contractor (no contract) | Out-of-pocket expenses |
GitHub renders each YAML template as a web form on the "New Issue" page; on submit, GitHub serialises the field values into the issue body as markdown. scripts/parse_issue.py then parses that markdown into a structured submission YAML (§4.7).
All three follow the same engine flow: form submitted → parser runs → PR opened with structured YAML + PDF + PNG preview → admin reviews and merges → ledger updated + payments-manager notified (Phase 2).
The interface contractors interact with. GitHub renders this YAML as a web form on the "New Issue" page; on submit, GitHub serialises the field values into the issue body as markdown. scripts/parse_issue.py then parses that markdown into a structured submission YAML.
Form fields:
- Contract (dropdown, required) — populated with the contractor's active contract IDs. Onboarding script writes the initial list; admin edits the list when a contract is renewed.
- Year (dropdown, required) — 4-digit year. Short list (~3-4 entries: current year ± a year for back/forward catch-up); admin appends one entry when the year rolls over.
- Month (dropdown, required) — static list
01through12. Parser is lenient (accepts5,05, and legacy05 — Mayfrom pre-Phase-3c issues) but the dropdown serves only the digits. The "— January" suffix was dropped in Phase 3c because the em-dash made post-creation body edits painful, and the rendered PDF only uses the numeric period anyway (2026-05) — see §8 Phase 3c. - Time Entries (textarea, required) — one row per day worked, pipe-delimited
YYYY-MM-DD | hours | description. Variable rows: contractor only enters days they actually worked, not a fixed grid of 30 rows. - Additional notes (textarea, optional) — free text.
- Confirmation (checkbox, required) — single ack of accuracy.
The period is computed as {year}-{month[:2]} by the parser (e.g. 2026-07). Splitting Year and Month means the admin only edits the Year list annually, not the full twelve-row Period list. Same pattern across all three submission forms (§4.6, §4.7).
The form file (post-substitution example):
name: 📋 Hourly Timesheet
description: Submit a monthly timesheet for hours worked on an hourly contract.
title: "Timesheet submission"
labels: ["timesheet", "pending-review"]
body:
- type: markdown
attributes:
value: |
## Hourly Timesheet Submission
Fill out the fields below. On submission, a Pull Request will be
automatically created with the structured data. An admin will
review and merge; on merge a PDF is generated and the payments
manager is notified.
**Corrections after submitting:** edit this issue (the PR will
be regenerated), or edit the PR branch directly if you're
comfortable with git.
- type: dropdown
id: contract
attributes:
label: Contract
description: Which contract does this timesheet apply to?
options:
- QE-PSL-2026-001 # populated by onboarding/new-contractor.py
validations:
required: true
- type: dropdown
id: year
attributes:
label: Year
description: Calendar year.
options:
- "2025"
- "2026"
- "2027"
validations:
required: true
- type: dropdown
id: month
attributes:
label: Month
description: Calendar month.
options:
- "01 — January"
- "02 — February"
- "03 — March"
- "04 — April"
- "05 — May"
- "06 — June"
- "07 — July"
- "08 — August"
- "09 — September"
- "10 — October"
- "11 — November"
- "12 — December"
validations:
required: true
- type: textarea
id: entries
attributes:
label: Time Entries
description: |
Enter one row per day worked, in the format:
`YYYY-MM-DD | hours | description`
Hours may be fractional (e.g. 4.5). Descriptions may contain
any text — the parser splits on the first two `|` only.
placeholder: |
2026-04-06 | 3.5 | NumPy lecture exercises review
2026-04-13 | 5.0 | Plotting examples
2026-04-20 | 4.0 | CI pipeline fixes
render: text
validations:
required: true
- type: textarea
id: notes
attributes:
label: Additional notes (optional)
placeholder: e.g. "Travel time on the 15th not included."
validations:
required: false
- type: checkboxes
id: confirmation
attributes:
label: Confirmation
options:
- label: I confirm that the hours and descriptions above are accurate.
required: trueA sibling config.yml disables blank issues and points contractors at the guide:
# .github/ISSUE_TEMPLATE/config.yml
blank_issues_enabled: false
contact_links:
- name: How to submit a timesheet
url: https://github.com/QuantEcon/contractor-payments/blob/main/docs/CONTRACTOR_GUIDE.md
about: Step-by-step guide with screenshotsParser tolerances — parse_issue.py accepts common variations:
- Date formats:
YYYY-MM-DDcanonical; also acceptYYYY/MM/DDandDD-MM-YYYYif the month is unambiguous. - Hour-unit suffixes stripped:
4.5,4.5h,4.5 hrsall parse to4.5. - Delimiters:
|canonical; also accept,or tab if consistently used in the input (emit a non-blocking warning comment to the issue). - Whitespace normalised; blank lines and obvious header rows skipped.
- Description content may contain
|— parser splits on the first two pipes only, so the third "field" captures everything after.
Parser must reject with line-specific errors:
- Date that can't be parsed at all.
- Date outside the selected
Period. - Two rows with the same date (duplicate-day check).
- Hours ≤ 0 or > 24.
- Missing fields (fewer than three pipe-separated segments).
Validation runs across three layers so that good submissions sail through, bad submissions get specific feedback, and admins only see well-formed PRs.
Layer 1 — Form constraints. Dropdowns for Contract and Period make those fields typo-proof. Required fields and the confirmation checkbox are enforced by GitHub at submit time.
Layer 2 — CI parsing. On issues: opened and issues: edited, the workflow runs parse_issue.py. Outcomes:
- Parse succeeds, no PR exists: workflow creates a branch, commits the structured submission YAML, opens a PR with
Closes #{issue-number}in the body. Removes any previous error comment from the issue. - Parse succeeds, PR already exists (contractor edited the issue to fix something post-submission): workflow regenerates the submission YAML on the existing PR branch, force-pushes, posts a comment on the PR noting the regeneration. Deferred from first ship — until built, contractors fix post-submission issues by editing the PR branch directly.
- Parse fails: no PR is created or modified. Workflow posts an error comment on the issue (or updates the existing one) and applies a
parse-errorlabel. Issue stays open.
Layer 3 — PR review. Admin merges or requests changes via standard PR review. Catches semantic errors (hours don't match the work described, wrong period selected, etc.) that no parser can detect.
Error comment format. Comments are written by the workflow with an HTML sentinel marker. On re-run after a failed edit, the workflow finds the previous comment by the sentinel and edits it in place — no comment spam.
🤖 **Submission needs a fix**
I couldn't parse the time entries. Here's what I found:
- **Line 3:** couldn't read a date from `2025/01/05` — please use
hyphens, e.g. `2025-01-05`.
- **Line 7:** date `2025-02-03` is outside the selected period
`2025-01`. Either change the date or pick a different period.
To fix, **edit this issue** (click the ⋯ menu → Edit) and update
those lines. I'll re-check automatically when you save.
<!-- timesheet-parse-error -->Triggers and what the workflow ignores.
- Re-runs on
issues: openedandissues: editedonly. - Does not run on new comments — the issue body is the form data; comments are for human conversation.
- Does not auto-close issues on failure. Issues close only when the linked PR merges (via
Closes #N).
State cleanup on successful re-parse.
parse-errorlabel removed.- Previous error comment removed (or rewritten as a success acknowledgement — exact wording decided during build).
- PR opens at most once per issue (creation on first successful parse; updates via force-push on subsequent successful parses, once that path is built).
For contractors on a milestone contract. The contract is lightweight metadata (§4.2); the contractor enters the milestone row themselves at submission time — same UX shape as a timesheet entry, with Hours replaced by Amount and one row per milestone claimed.
Form fields:
- Contract (dropdown, required) — populated with the contractor's active milestone contract IDs (hourly contracts excluded).
- Year + Month (dropdowns, both required) — same two-dropdown pattern as §4.4. Parser combines them to
YYYY-MM. - Milestone entries (textarea, required) — one row per milestone claimed, pipe-delimited
ID | YYYY-MM-DD | amount | description. Typically a single row (one milestone per submission); multi-row supported for catch-up submissions when an RA forgot to file for a prior month, or for the rare case of two milestones delivered in one period. TheIDis the milestone number from the contract's schedule (e.g.3for "Payment 3 of 6") — the contractor reads it off the contract'snotes(§4.2) and types it in. Currency is fixed by the contract. - Additional notes (textarea, optional) — free text.
- Confirmation (checkbox, required) — single ack.
Parser tolerances — same lenient rules as timesheet rows:
- Date formats:
YYYY-MM-DDcanonical;YYYY/MM/DDandDD-MM-YYYYaccepted when unambiguous. - Delimiters:
|canonical;,or tab accepted with a non-blocking warning. - Description may contain
|— parser splits on first three pipes. - ID is a free-form string (typically an integer like
3, but the system accepts any token).
Parser must reject:
- Date that can't be parsed.
- Date outside the selected
Periodis allowed without warning for milestone submissions — catch-up cases legitimately reference dates from prior months. (Contrast with timesheets, where out-of-period dates are rejected.) - Amount ≤ 0.
- Missing fields (fewer than four pipe-separated segments).
- Duplicate
IDwithin the same submission.
Engine cross-check (Phase 3b). Parser checks each submitted milestone id against the contract's structured milestones[] list (§4.2). Mismatches surface as non-blocking warnings in the PR body — alongside other parse warnings — so the admin sees them at review time without the engine refusing the submission. Same posture as everywhere else: engine warns, admin decides.
Admin responsibility on review. Verify during PR review that: (a) the submitted amount matches the contract's milestones[] entry for that ID, (b) this milestone hasn't already been claimed in a prior submission. The merged ledger (ledger/{contract_id}.yml) is the cumulative record to check against.
Submission ID: {handle}-invoice-{period} (e.g. mmcky-invoice-2025-11). Period-based for consistency with timesheets; collision suffix -vN applies the same way (§v1.1).
Filed against the contractor, not a specific contract. RAs and staff under a contract submit reimbursements here for out-of-pocket expenses (travel, equipment, software, etc.). Approval is per-claim via the standard PR review flow — there is no pre-authorization in a contract.
A single reimbursement claim covers one period (calendar month) and may bundle multiple line items incurred on different dates within that month — e.g. one trip with flight + hotel + meals across four days.
Form fields:
- Year + Month (dropdowns, both required) — same two-dropdown pattern as §4.4. Parser combines them to
YYYY-MM. The period is the month the claim is filed against, not necessarily when the expense was incurred (though usually the same month). - Line items (textarea, required) — one row per receipt, pipe-delimited:
YYYY-MM-DD | amount | category | description. Currency is fixed for the submission (see field 3); per-line currency mixing is out of scope in v1. Categories must match the contractor repo'sconfig/settings.ymlallowed list. - Currency (dropdown, required) — ISO 4217 code; same supported list as contracts (
AUD | USD | JPYin v1, extensible). One currency per submission. - Total amount (number, required) — contractor enters the total; parser verifies it matches the sum of line-item amounts (rejects on mismatch as a sanity check).
- Trip / project context (textarea, optional) — free text. Useful for trips where line items don't individually justify their purpose.
- Receipts — see "Receipt storage" below.
- Confirmation (checkbox, required) — single ack.
Receipt storage — DEFERRED. Where receipts physically live (GitHub issue attachments? Committed PDFs in receipts/<period>/? External store?) is an open decision (see §10). The Reimbursement engine ships in Phase 5 (post-launch — see §8) after this decision is made and the multi-currency design is settled; the form schema above will pick up a receipts: field and a per-line-item currency column then.
Parser must reject:
- Any line-item date outside the selected
Period(warn rather than reject if a trip legitimately spans a month boundary — exact policy decided during build). - Sum of line items ≠ stated total.
- Empty line items.
- Currency not in supported list.
- Category not in the contractor repo's allowed list.
Submission ID: {handle}-reimbursement-{period} (e.g. mmcky-reimbursement-2025-09). Collision suffix -vN applies if a second reimbursement is filed for the same month — a legitimate case (multiple trips in one month), distinct from revisions.
After parsing, all three submission types persist to submissions/{period}/{submission_id}.yml. The engine layer (PDF render, ledger update, payments-manager notify) consumes a common shape with a type discriminator:
# Common fields (all types)
submission_id: <handle>-<type>-<period>[-vN]
type: hourly | milestone_invoice | reimbursement
period: YYYY-MM
submitted_date: YYYY-MM-DD # in payer's timezone (§9)
submitted_by: <github-handle>
issue_number: <int>
status: pending | approved | superseded
approved_by: <github-handle | null>
approved_date: YYYY-MM-DD | null
# Type-specific blocks (exactly one of the following groups present)
# --- hourly ---
contract_id: ...
entries:
- {date: ..., hours: ..., description: ...}
totals:
hours: ...
rate: ...
amount: ...
currency: ...
# --- milestone_invoice ---
contract_id: ...
entries:
- {id: ..., date: ..., amount: ..., description: ...}
totals:
amount: ... # sum of entries[].amount
currency: ... # from contract
# --- reimbursement ---
# contract_id intentionally absent — reimbursements are contractor-level
line_items:
- {date: ..., amount: ..., category: ..., description: ..., receipt: <path>}
trip_context: |
...
totals:
amount: ... # sum of line_items[].amount
currency: ... # one currency per submissionThis shared shape means Phase 2 merge processing — ledger update, approval re-render, payments-manager notify — runs the same pipeline for all three types, branching only at the render-template selection and the per-type ledger writer.
A single interactive Python script. Stdlib argparse + pyyaml + subprocess to gh. Run from a clone of QuantEcon/contractor-payments.
- Prompts for (with reasonable defaults where applicable):
- GitHub handle of the new contractor
- Real name
- Payments manager GitHub handle (defaulted from a config or prior run)
- First contract: type (hourly | milestone), start date, end date, rate (hourly) or schedule notes (milestone), currency (AUD / USD / JPY; validates against the v1 supported list), project funding code (e.g. CHOW), role
- Creates
QuantEcon/contractor-{handle}as a private repo. - Seeds the repo from
contractor-template/, substituting prompted values intoconfig/settings.yml,README.md,CODEOWNERS, and the contract YAML. Bothhourly-timesheet.ymlandmilestone-invoice.ymlissue templates are seeded unconditionally; the contractor's "New Issue" page surfaces whichever ones the dropdowns aren't empty for (which is governed by the contract types they have). - Generates
contracts/{contract-id}.ymlfrom the prompted contract details. - Adds the contractor (Write), admin (Admin), and payments manager (Read) as collaborators via
gh api. - Sets branch protection on
main(PR required, 1 review). - Creates the workflow labels via
gh label create(idempotent — skips any that already exist). Required because GitHub Issue Forms silently droplabels:values that don't exist on the repo, which would break the workflow's label-based routing:timesheet— applied by the Hourly Timesheet formmilestone-invoice— applied by the Milestone Invoice formpending-review— applied by both submission formsparse-error— applied by the workflow on parse failuresubmission— applied by the workflow when opening the submission PRprocessed— applied by Phase 2 merge processing- (Phase 3c)
submit— applied by the contractor to trigger PR creation on a draft issue; auto-removed by the workflow after handling so it can be re-applied - (Phase 5)
reimbursement— applied by the Reimbursement Claim form, added when Phase 5 lands
- Pushes the initial commit.
- Prints the contractor-facing URL and next steps.
Phase 5 will add a multi-select for which issue templates to seed (Hourly Timesheet / Milestone Invoice / Reimbursement Claim), letting the admin configure reimbursement-only payees or any other subset. Until then, the script seeds both Phase 1/1.5 templates by default.
- No contract PDF generation (contracts are YAML metadata; no signed PDF in v1).
- No contract renewal / end automation — admin edits YAML by hand.
- No central record of which contractors exist (you can
gh repo list QuantEcon --topic contractorif you tag the repos, or listcontractor-*repos viagh repo list). - No batch operations or template re-sync — when workflows in
QuantEcon/contractor-paymentschange, contractor repos that reference them via reusable workflows pick up the change automatically. Files copied fromcontractor-template/are only re-synced manually if needed.
- Idempotent for re-runs: if the repo already exists, the script reports and exits non-zero rather than overwriting.
- Substitution uses stdlib
string.Template. - All GitHub operations use
ghCLI subprocess calls; no Python GitHub libraries.
| Decision | Choice | Notes |
|---|---|---|
| Submission types | Hourly Timesheet, Milestone Invoice, Reimbursement Claim | All three planned architecturally (§4.3). Phase 1–4 ship Hourly + Milestone; Reimbursement deferred to Phase 5 (post-launch) because of multi-currency complexity and the receipt-storage open question (§10). |
| Per-contractor repo name | QuantEcon/contractor-{github-handle} |
Future-proof for other contractor artefacts. |
| Contract data | Plaintext YAML in each contractor's repo | Admin-edited by hand. |
| Contract ID convention | QE-PSL-YYYY-NNN |
Documented in §4.2. System accepts any string; onboarding pre-fills this format. |
| Contract listing on issue form | Static dropdown in the form YAML | Onboarding script seeds the initial list; admin edits on contract renewal. |
| Submission ID | {handle}-timesheet-{period} with -v2, -v3 collision suffix |
Period-based for readability; suffix handles re-submissions for the same period. v1.1 polish layer for explicit revision metadata (§8). |
| Approval notification | Email to PSL (Cc the QuantEcon reviewer) via SMTP with the approved PDF attached, plus an internal GitHub comment confirming the send. | PSL doesn't use GitHub; email is the natural delivery channel. The GitHub comment is verbose by design — gives admins operational visibility and confirms the email step ran. fiscal-host.yml.notifications.testing_mode flag gates PSL while we iterate (vars.QUANTECON_EMAIL_REVIEWER only during testing). |
| PDF generation | Typst in CI; rendered at PR-creation, regenerated at merge with approval metadata | Committed to generated_pdfs/<YYYY-MM>/. PR carries a "PENDING REVIEW" PDF; merge replaces it with the approved version. |
| PNG preview | Same template rendered to PNG; committed alongside the PDF | Embedded inline in the PR body via absolute raw URL so reviewers see the artifact in the PR description without leaving the review surface. Default 200 PPI, --png-ppi overrides. |
| Fiscal-host identity & policy | templates/fiscal-host.yml (engine repo) — PSL Foundation address + timezone + email notification recipients; QuantEcon logo-only (no address). |
Single source of truth across all contractor repos. Holds all fiscal-host config that's identical across payees. |
| Document issue dates | Computed in the payer's timezone (psl_foundation.timezone in fiscal-host.yml, default America/New_York) |
All contractors' submission/approval dates use the same locale as the payer's books, regardless of where the contractor lives. Falls back to UTC if the field is unset. |
| Contractor address | Optional contractor.address in settings.yml (multi-line) |
Renders on the PDF only when populated. Recommended for tax-invoice compliance. |
| Ledger / running totals | Yes | One ledger/<contract-id>.yml per contract; updated on merge. |
| Onboarding | Interactive Python script | See §5. |
| Encryption at rest | None | Each repo is one contractor; blast radius is naturally scoped. |
| Receipt storage | Decision pending (§10) | Required before Reimbursement engine (Phase 5, post-launch) ships. Options on the table: GitHub issue attachments, committed PDFs in receipts/<period>/, or external store. |
| Currency | Per-contract; AUD, USD, JPY supported in v1 | Specified in each contract YAML (§4.2). JPY rendered without decimals; AUD/USD with two. ISO code as suffix, no symbols. |
| Cross-contractor reporting | Out of scope | Captured in the broader admin infrastructure issue. |
- Contractor opens
github.com/QuantEcon/contractor-{theirhandle}(bookmarked). - Issues → New Issue → 📋 Hourly Timesheet → fill out form → submit.
issue-to-pr.ymlparses the form, writes the submission YAML insubmissions/<YYYY-MM>/, renders the "PENDING REVIEW" PDF and a PNG preview ingenerated_pdfs/<YYYY-MM>/, opens a PR with the PNG embedded inline in the description.- CODEOWNERS auto-requests review from admin. Contractor + admin get notifications.
- Reviewer sees the PNG inline in the PR; clicks through to the PDF if they want the authoritative artifact.
- Corrections: contractor edits the PR branch directly, or admin requests changes via PR review.
- Admin approves and merges.
process-approved.ymlidentifies the merged submission.- Updates
ledger/<contract-id>.ymlwith the new totals. - Re-renders the PDF with approval metadata (
approved_by,approved_dateset), replacing the "PENDING REVIEW" version that the PR carried. - Comments on the now-closed issue:
@{payments_manager} Approved — {real name} — PDF: <blob URL>. - Applies
processedlabel.
python onboarding/new-contractor.py— answer the prompts.- Script creates the repo, seeds it, adds collaborators, pushes. Prints the URL.
- Admin sends the repo URL + the docs site (https://quantecon.github.io/contractor-payments/) to the new contractor.
- In the contractor's repo, copy
contracts/{old-contract-id}.ymltocontracts/{new-contract-id}.yml. - Edit dates / rate / status as needed.
- Mark the old contract
status: ended. - Edit
.github/ISSUE_TEMPLATE/hourly-timesheet.yml— add the new contract ID to theContractdropdown options, remove the ended one if appropriate. - Commit and push.
No CLI, no ceremony. One additional file to edit beyond the contract YAML (the form's dropdown), captured here so it doesn't get missed.
- Create
QuantEcon/contractor-payments - Tighten
PLAN.mdto v1 scope - Open broader infrastructure issue in
QuantEcon/admin(#5) - Consistency pass on
PLAN.md - Resolve open items in §10 (payments manager handle, admin handle/team, org-level reusable-workflow setting, runner-minutes budget)
Built everything against QuantEcon/contractor-engine-test. All three flows (valid submission, invalid submission, fix-and-retrigger) verified end-to-end against live GitHub. PDF + PNG preview rendering pulled forward from Phase 2 so reviewers see the actual artifact during PR review.
Engine scripts and templates:
-
scripts/parse_issue.py+ tests — parser with lenient input handling and line-specific errors (§4.3, §4.4) -
scripts/create_submission_pr.py+ tests — period-based submission IDs with-vNcollision suffix; renders PDF + PNG; opens PR with the PNG embedded inline in the body -
scripts/post_error_comment.py+ tests — sentinel-marked error comment on parse failure; updates in place on re-run -
scripts/generate_pdf.py— Typst PDF + PNG with currency-aware display formatting; configurable PNG PPI -
templates/timesheet.typ— single-page A4 template fitting up to 31 entries (worst-case month) -
templates/fiscal-host.yml— PSL Foundation address; single source for both organisations -
templates/assets/{quantecon,psl-foundation}-logo.png— branding
Form, workflow, test repo:
-
contractor-template/.github/ISSUE_TEMPLATE/hourly-timesheet.yml+config.yml(§4.3) -
contractor-template/.github/workflows/issue-to-pr.yml— non-reusable Phase 1 form; installs Typst 0.13.0 via curl -
QuantEcon/contractor-engine-test(private, disposable) seeded with the above + a hand-writtenconfig/settings.ymland onecontracts/QE-PSL-2026-001.yml - End-to-end verified: valid submission → PR with YAML + PDF + PNG; invalid → sentinel error comment + label; edit-to-fix → state cleaned up; revision (same period re-submit) →
-v2suffix applied
Project scaffold:
-
pyproject.toml,.gitignore,tests/(80 unit tests passing across three files)
Adds the second submission type alongside hourly. Engine pieces largely shared with hourly (parser plumbing, PR-creation flow, sentinel error comments).
Engine + form + tests:
- Contract schema extension —
type: milestone(lightweight metadata; admin verifies during PR review per §4.2 decision). -
scripts/parse_issue.py— auto-detects submission type from issue body; new milestone path parsesID | YYYY-MM-DD | amount | descriptionrows. Period dropdown split into Year + Month across all forms. -
contractor-template/.github/ISSUE_TEMPLATE/milestone-invoice.yml— new form (§4.6). -
templates/invoice.typ— Typst template (titleQUANTECON INVOICE; 4-col ID/Date/Amount/Description table; single Amount payable row). -
scripts/create_submission_pr.py— branches on submission type; submission ID becomes{handle}-invoice-{period}; PR gets type-specific label. -
scripts/generate_pdf.py— selects template by submission type via a registry. -
contractor-template/.github/workflows/issue-to-pr.yml— routes bothtimesheetandmilestone-invoicelabels through one pipeline (parser auto-detects). -
scripts/setup_labels.py— idempotent label bootstrap (gap surfaced in this phase: GitHub Issue Forms silently drop unknown labels). Phase 3b's onboarding will call this. - 107 tests passing (51 hourly parser, 15 milestone parser, +20 misc).
- End-to-end verified: opened a milestone-invoice issue on
contractor-engine-test, workflow produced a PR with YAML + PDF + PNG, parse-error label cleanup confirmed.
Repo housekeeping:
- Renamed
QuantEcon/timesheets→QuantEcon/contractor-payments. Engine repo URL refs + local clone path updated.
Phase 1.5 surfaced an operational risk: contractor repos carried their own copies of scripts/ and templates/, so engine repo updates didn't propagate automatically. This phase replaced those copies with workflow_call references back into QuantEcon/contractor-payments, so every push to the engine repo is live on every contractor repo immediately.
- Engine repo workflow access —
actions/permissions/accessset toorganizationonQuantEcon/contractor-paymentsso other org repos can call its reusable workflows. - Engine repo visibility flipped to public — required for
actions/checkouton the engine repo from a contractor repo's workflow (the caller'sGITHUB_TOKENis scoped only to itself; a PAT would have added rotation overhead with no real benefit since the engine carries no data). Trade-off accepted; recipient emails now live in org-level Variables instead of committed files (see §9 Email recipient policy). - Engine repo:
.github/workflows/process-submission.yml—on: workflow_call. Two checkouts (contractor repo at working dir, engine at./engine). Scripts run withPYTHONPATH=engineand--templates-dir engine/templates. - Contractor-template
.github/workflows/issue-to-pr.yml— collapsed to a thin caller:uses: QuantEcon/contractor-payments/.github/workflows/process-submission.yml@main, passes thegithub.event.issuecontext, applies the label-gate predicate,secrets: inheritfor future SMTP credentials. -
contractor-engine-test— workflow replaced with the thin caller;scripts/+templates/directories deleted (2,306 lines removed). Engine repo is now the only source of truth. - End-to-end verification — opened issue #13 on
contractor-engine-testvia the new thin caller; workflow ran cleanly through the reusable workflow, opened PR #14 with correct YAML + PDF + PNG.
On PR merge, the engine runs process-approved.yml (implemented as a reusable workflow). Designed generic so it covers all in-scope submission types (hourly + milestone); the same pipeline picks up reimbursement when Phase 5 lands.
Status: engine code complete. Partial E2E verified through step 6 (ledger-issue refresh); step 7 (email send) is gated on the SMTP credentials in §10. Once the credentials land, one fresh merge will exercise the full pipeline.
Approval re-render + ledger:
- Re-render PDF + PNG with approval metadata baked in —
scripts/finalize_approval.py(4619305). Stamps the submission YAML withstatus: approved,approved_by,approved_date(default: today in fiscal-host timezone), then re-renders PDF + PNG via the existing render functions. The Typst template's existing pending-vs-approved conditional automatically flips the amber "PENDING REVIEW" block to the green "✓ APPROVED — by @... on ..." block. -
scripts/update_ledger.py(f348485) — appends the approved submission toledger/<contract-id>.yml. Branches by type: hourly writessubmissions[]+hours_to_date; milestone writesclaims[]+claims_count. Idempotent against duplicatesubmission_id(raises). Pure file mutation; no external services. 17 unit tests covering both type branches, currency-aware rounding, and the cross-checks. -
scripts/update_ledger_issue.py(60bdd9c) — renders the ledger YAML as a markdown table and edits the pinned GitHub issue in the contractor repo (located viacontract.ledger_issue). Locked from comments so it stays automation-only. Marker comment<!-- ledger-issue-marker:<contract-id> -->in the body for safe identification. Skips with a warning (doesn't fail the workflow) whenledger_issueis absent from the contract YAML.
Email delivery to PSL:
-
scripts/notify_email.py(60bdd9c) — composes plain-text email body + PDF attachment, sends via stdlibsmtplib+ STARTTLS. Subject:[QuantEcon] {Type} approved — {Real Name} — {Period} — {Amount} {Currency}. Recipients:vars.PSL_EMAIL(To) +vars.QUANTECON_EMAIL_REVIEWER(Cc) whentesting_mode: false;vars.QUANTECON_EMAIL_REVIEWERonly whentesting_mode: true. Reply-To set tosecrets.SMTP_FROM(the payments@ alias) so PSL's "Reply" routes back to the sending mailbox (where the existing label/filter picks it up); "Reply All" additionally reaches the reviewer Cc. Dry-run smoke-tested locally — fixture composes cleanly with all the expected headers + attachment metadata. -
scripts/notify_comment.py(60bdd9c) — posts the audit comment on the now-closed issue confirming approval + ledger update + email send (recipients + send timestamp +testing_modeflag). Verbose by design — three-line summary at a glance gives the admin team operational visibility, and surfaces partial failures (e.g. "email not sent — see workflow logs") rather than failing silently. - Workflow ordering — finalize_approval → update_ledger → commit → update_ledger_issue → notify_email → notify_comment → apply
processedlabel. The comment runs last and reflects the email outcome via the JSON summary thatnotify_email --output-summarywrites for it. -
.github/workflows/process-approved.yml(c190514 + 59102f5 fix) — engine reusable workflow onworkflow_call. Two checkouts (contractor repo + engine repo at./engine), Python + Typst setup, then chains the five scripts. Pushes the re-rendered files + ledger update back to main with[skip ci]. Caller workflow lives incontractor-template/.github/workflows/process-approved.yml; thin caller filters to merged PRs with thesubmissionlabel. - GitHub org-level secrets — all five SMTP secrets (
SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASSWORD,SMTP_FROM) set onQuantEcon(Private repositories visibility).SMTP_USER=admin@quantecon.org(the authenticated mailbox),SMTP_FROM=payments@quantecon.org(an alias of admin@),SMTP_PASSWORDis a dedicated Google app password.
Documentation:
-
notes/EMAIL_SETUP.md(f9a5550 + 4619305) — Gmail / Google Workspace setup runbook. Three options for the sending identity (alias of existing account / standalone user / Google Group — alias is the recommended path since it matches QuantEcon's actual setup). Walks through 2-Step Verification, dedicated app-password generation, secret population, local smoke test, troubleshooting, Reply-To handling.
End-to-end:
- Manual one-shot: initial ledger issue (#15) opened on
contractor-engine-testfor contractQE-IUJ-2025-002— pinned, locked, labelledger, body pre-populated with the empty-state markdown. Issue number written intocontracts/QE-IUJ-2025-002.ymlasledger_issue: 15. (Phase 3b onboarding will automate this for real contractor repos.) - Partial E2E verified — opened test issue #16, workflow created PR #17, admin merge fired
process-approved(run 25780661113). 6 of 7 pipeline steps green: PDF/PNG flipped amber → green, ledger YAML committed, pinned issue #15 auto-refreshed to show the new claim. Email step failed as expected withoutSMTP_PASSWORD; comment + label steps skipped. - Full E2E — pending SMTP credentials (see §10). Once those are set: open one more test submission + merge → confirm email lands in the
vars.QUANTECON_EMAIL_REVIEWERmailbox (testing_mode keeps PSL off), audit comment posts on the issue,processedlabel appears on the PR.
Once Phase 3a + Phase 2 implemented, the discipline was to stop and test thoroughly before continuing. During this phase:
notifications.testing_modestayed true — the mailbox referenced byvars.QUANTECON_EMAIL_REVIEWERreceived all approval emails;vars.PSL_EMAILwas never contacted.- Full E2E verified on
contractor-engine-test(issue #18 / run 26006508196): submit → review → merge → email lands → audit comments on both issue + PR → ledger refreshed →processedlabel applied. testing_mode: trueis retained through Phase 2.5 and Phase 3b. The flip totesting_mode: falsehappens at the start of Phase 4.
The testing surfaced an operational design question (post-merge corrections), captured and addressed in Phase 2.5 below.
End-to-end verified on contractor-engine-test (2026-05-18):
- Revision flow: edit closed issue #18 body → engine detected revision via filesystem state → opened PR #20 (
(revision)title suffix, REVISION banner on PDF,supersedes+revision_ofmetadata) → admin merged → email landed withREVISION approvedsubject → ledger replaced original with v2 (77,000 → 80,000 JPY; superseded entry rendered struck-through in pinned issue #15) → cross-comment posted on the now-superseded PR #19. - Independent
-Bflow: fresh issue #21 for the same period → engine detected collision but no revision target → assigned-Bsuffix purely for ID uniqueness, no metadata, no banner → admin merged PR #22 → email plain "approved" subject → ledger appended as a third active claim (total now 172,000 JPY across 3 claims; the still-superseded original excluded).
Two bugs were surfaced and fixed during E2E:
- Branch existence ≠ open PR; gating regeneration on
gh pr list --state opennot on branch existence (b362bf4). - Cross-comment search was substring-matching the revision's own PR body; tightened to
<id>.yml+ explicit current-PR exclusion (39c7792).
Two follow-on polish items also landed during the same testing window:
- CODEOWNERS added to
contractor-template/.github/CODEOWNERS(with$ADMINplaceholder, substituted by Phase 3b onboarding); deployed literally tocontractor-engine-test. Auto-requests admin review on every PR — sharper signal than the @mention buried in the PR body, and surfaces on the PR as state until reviewed (e1bf74b). - Email body uses
approved_byfrom the stamped submission: "Approved by QuantEcon @{handle} for processing." instead of the prior generic "admin" string (e1bf74b).
Motivation (surfaced during BREAK testing — see notes/ADMIN_RUNBOOK.md Scenario 4).
Once a submission PR is merged, the email has gone to PSL, but the funds haven't moved yet, there's a real operational window — typically 1–4 weeks for a fiscal-host batch-payment cycle — during which an error may be discovered. The original v1 plan ("v1.1 — Revision / supersede handling, build when first real correction happens") deferred this; the BREAK testing phase concluded that the first real correction will be high-stakes and we want engine support in place before real contractors onboard, not after.
Two-mechanism model.
| Trigger | Intent | Identifier | Ledger effect |
|---|---|---|---|
| Edit issue body (pre-merge) | Fix in place | unchanged (no suffix) | n/a — never committed |
| Reopen closed issue + edit | Revision — supersede previous | {base}-v2, -v3... |
Replace old entry with new |
| Open new issue (same period) | Independent second invoice | {base}-B, -C, -D... |
Append as new entry (normal flow) |
Key semantic distinction. Only the revision side carries cross-document semantics (supersede metadata, PDF banner, cross-comment between PRs, email subject prefix). The -B, -C suffix is purely an identifier uniqueness mechanism — those invoices are conceptually independent and just happen to share a period (e.g. two milestones delivered in the same month, two billing cycles colliding). The engine doesn't track or render any relationship between them.
When to use each (admin / contractor judgment):
- Pre-merge: edit the issue in place (Scenario 2 in the runbook).
- Post-merge, pre-PSL-payment: reopen the original issue, edit it → revision (
-v2). PSL gets the corrected version; ledger reflects only the corrected amount. - Post-PSL-payment, or any case of a genuinely independent additional invoice in the same period: open a new issue →
-Bfor uniqueness. Treated as a normal invoice in every other respect.
The engine doesn't track PSL payment state (out-of-band). Admin uses judgment based on the payments@ inbox.
Identifier naming (formalising the §9 Resolved decisions row).
- Original:
{handle}-{type}-{period}— e.g.mmcky-invoice-2026-02 - Revision: append
-vN— e.g.mmcky-invoice-2026-02-v2(thevcarries supersede semantics) - Independent second invoice in same period: append
-{LETTER}from B upward — e.g.mmcky-invoice-2026-02-B(uniqueness only; no semantic claim about relationship) - Composed:
-B-v2(a revision of-B)
v and letter are distinct namespaces; reads unambiguously to both humans and the parser.
Build tasks (revision side — the side with semantics):
- Engine: detect reopen as the revision trigger.
- On
issues.reopenedevent, the workflow queries the issue's PR cross-references for a merged PR. If found → revision; the latest merged PR is the supersession target (handles revision-of-revision chains correctly). If not found (edge case: issue was manually closed without a merge) → treat as a fresh submission with no-vNsuffix; engine proceeds through the normal pipeline.
- On
- Revision YAML metadata. Stamp
supersedes: <previous-id>andrevision_of: <original-id>(chain anchor for revision-of-revision cases). On the approved-then-superseded original onmain: post-merge update addssuperseded_by: <new-id>andstatus: superseded. - Revision PDF banner. When
supersedesis set, render a "REVISION — supersedes <previous-id>" banner at the top of the PDF. - Revision ledger semantics. Remove the superseded entry from
ledger/<contract-id>.yml, append the new one. Netclaims_count/submissions_countstays the same when replacing one entry with one. - Revision cross-references. On merge of a revision PR: post a comment on the previous (closed) PR — "Superseded by #{new-pr-number}. The PDF and audit trail above remain as the record of what was originally sent to PSL."
- Revision email subject prefix.
[QuantEcon] {Type} REVISION approved — ...so PSL spots the correction in their inbox. Body unchanged (PDF banner already declares it; redundant body text would just add noise). - Revision PR title. Append
(revision)to the PR title — e.g.Submission: <issue-title> (revision)— so reviewers see the relationship at a glance in the PR list. Issue title stays as the contractor wrote it.
Build tasks (uniqueness side — minimal):
- Engine: detect collision on
issues.openedand assign next available letter suffix from B onward (skipping any already insubmissions/). No metadata stamping, no banner, no special workflow path beyond ID assignment. The submission proceeds through the normal pipeline.
Cross-cutting build tasks:
-
create_submission_pr.pysuffix logic rewritten to compute the next available-vN(reopen path) or-{LETTER}(collision-on-open path) based on whichever trigger fired plus already-committed state. -
update_ledger.pybranches onsupersedesmetadata (revision path replaces; otherwise appends — covers both originals and-B/-Cinvoices uniformly). -
update_ledger_issue.pyrenders superseded entries struck-through with a link to the revision and excludes them from the running totals. Keeps the audit trail discoverable; keeps the totals accurate. (Decided 2026-05-18.) - Tests — parser + ledger + create_submission_pr coverage for: revision of original, revision of revision,
-Bthen revision of-B, multiple-B/-Cindependent invoices in same period. - End-to-end test on
contractor-engine-test— exercise both a revision flow (reopen + edit) and an independent-second-invoice flow (-B). Verify ledger arithmetic, cross-references on the revision side, and that the-Bside is indistinguishable from a normal submission downstream of ID assignment.
Accounting principle: every issued invoice number stays a record. Cancellation isn't a thing in good practice — supersession is. The original PDF remains in generated_pdfs/ as the record of what PSL was originally sent. Independent invoices in the same period stand on their own.
-
onboarding/new-contractor.pyper §5 — seeds both Hourly Timesheet and Milestone Invoice templates unconditionally; creates the contractor repo; adds collaborators; creates labels viascripts/setup_labels.py; opens the pinned ledger issue and wiresledger_issue: <N>back into the contract. Flags-with-prompt-fallback, plus--config <yaml>for the full record as a reviewable artifact (admin-private files underonboarding/contractors/, gitignored). Multi-select for templates deferred to Phase 5. - Repo settings to apply on creation (every new contractor repo gets the same baseline):
delete_branch_on_merge: true— keeps the branch list clean after each approved submission. (gh api -X PATCH /repos/{owner}/{repo} -f delete_branch_on_merge=true.)- Branch protection on
main— DEFERRED (status: no protection in place; revisit post-launch with GitHub App approach). Originally specified to be enforced via an org-level ruleset granting bypass to thegithub-actionsintegration. Reassessed 2026-05-20 when preparing for the first real contractor; the design as written no longer works:- Legacy/classic per-repo branch protection —
enforce_admins: true/falseboth break the workflow.trueblocks the[skip ci]bot push fromprocess-approved.yml(ledger + PDF re-stamp);falseonly exempts human admins, the bot is still rejected withGH006: Protected branch update failed — Changes must be made through a pull request.(Empirically verified Phase 2.5.) - Repo-level rulesets — can't grant bypass to the
github-actionssystem app (rejects with "Actor GitHub Actions integration must be part of the ruleset source or owner organization"). - Org-level rulesets — were the prescribed escape hatch in the original Phase 3b design, on the assumption that the org-level bypass picker exposes the GitHub Actions integration as a selectable actor. Re-verified 2026-05-20: the modern org-level bypass picker no longer surfaces "GitHub Actions" as a bypass actor (confirmed by filter-search returning "No suggestions" for
action,github). The original spec's path is therefore closed. - Reading the Phase 3b history charitably: the E2E run on
contractor-mmcky(2026-05-18, item further down) succeeded because the org had no ruleset in place — the workflow's[skip ci]push went through because nothing blocked it, not because bypass was working. Protection has effectively been off the whole time; the spec was prescriptive, never operational.
- Legacy/classic per-repo branch protection —
- Short-term decision (2026-05-20): ship the first real contractor with no branch protection on their repo. Acceptable because: (a) contractor repos are private, single human contractor + single admin reviewer, (b) the contractor has Write but no incentive to circumvent the flow that pays them, (c) anomalous direct-to-main activity would be visible on the repo timeline. The onboarding script will surface this gap explicitly so the admin can re-confirm acceptance at onboarding time.
- Future direction (Option A — GitHub App for the engine push). Create a dedicated org-owned GitHub App ("QuantEcon Contractor Engine") with
contents:write,pull-requests:write,issues:writepermissions, installed oncontractor-*repos. The workflow usesactions/create-github-app-tokento mint a short-lived push token; this token is owned by the App's bot identity (distinct from the built-ingithub-actions[bot]) which should be selectable in the org-level Ruleset bypass picker as an installed App. Empirical verification of this last assumption is a prerequisite before committing to the migration. See notes/ADMIN_RUNBOOK.md → Branch protection (deferred) for the detailed migration recipe. The migration is data-preserving: existing contractor repos retain all submissions / ledger / PDFs and only need (1) the App installed, (2) the engine workflow updated to use the App token (one-line change in the reusable workflow), (3) the ruleset created with the App in bypass. - Issue auto-deletion not enabled (we keep the submission issues as the audit trail).
- Opens the initial ledger issue for the first contract (per §8 Phase 2's
update_ledger_issue.pydesign). Pins it to the repo's Issues tab. Locks it from comments. Writes the issue number back intocontracts/<contract-id>.ymlasledger_issue: <N>so the approval workflow can find it. (Contract-renewal helper deferred — admin handles renewals manually until the pattern emerges.) - E2E run on a fresh repo (2026-05-18). Onboarded
QuantEcon/contractor-mmckyfrom scratch with the script, filed a test timesheet via the Issue Form, PR opened with rendered PDF preview + CODEOWNERS review request, merged it;process-approved.ymlupdatedledger/QE-PSL-2026-099.yml, re-stamped the PDF with approval metadata, refreshed the pinned ledger issue body, commented on the closed submission issue, and emailedmmcky@quantecon.org(testing_mode=true held PSL off). Test repo deleted after verification.
Motivation (decided 2026-05-19). Today, opening an issue via the Hourly Timesheet form auto-creates a PR on the first parse-success event. In practice an RA accumulates hours across days or weeks before they're ready to submit, which forces them into one of two awkward paths: (a) track hours in an external spreadsheet/notes app and paste a complete table into the form only when ready (extra tools, extra friction, no in-repo audit trail of the in-progress work), or (b) open the issue early and let the engine churn out PR regenerations on every body edit (PR opens against incomplete data; reviewer notifications fire prematurely; the issue is treated as a single-shot data drop rather than a working surface).
This phase reshapes the submission flow so the issue is a long-lived draft the RA edits in place, and PR creation is gated on an explicit submission trigger. A separate /validate comment lets the RA self-check parse-correctness before committing to submission.
Design — three changes to the existing model.
-
Issue body becomes the working draft. The form-on-creation still provides the structured/restricted UI for the initial seed (§4.4) — dropdowns for Contract/Year/Month, the required-confirmation checkbox, etc. The form seeds a header-bearing markdown table for entries (rather than just a placeholder). After issue creation the RA edits the issue body directly to add rows over time. HTML-comment format reminders (
<!-- Add new rows: | YYYY-MM-DD | hours | description | -->) are seeded next to the table — invisible in the rendered issue, visible every time the body is opened in the editor — so the template's guidance role persists through edits. -
PR creation is explicit, not automatic. Two triggers, both supported:
- Primary: comment
/submiton the issue. - Secondary: apply the
submitlabel.
Either fires the parser; on success the engine creates the submission PR (same downstream flow as today). On parse failure: error comment posted on the issue (same Layer-2 behaviour as §4.5), no PR created, the trigger is treated as not having fired (label removed by the workflow so re-application re-triggers).
- Primary: comment
-
/validatefor pre-flight checks. A/validatecomment runs the parser in dry-run mode and posts a sentinel-marked result comment on the issue — success preview or line-specific errors — without creating any branch, commit, or PR. Re-running/validateupdates the same comment in place (sentinel<!-- timesheet-validate-result -->). Lets contractors iterate to a clean parse before triggering the real submission.
Post-submission semantics.
- PR is canonical once opened; further corrections go via the PR branch (Phase 2.5 revision flow unchanged for post-merge corrections).
- On successful
/submit, the workflow closes and locks the originating issue with a comment linking to the PR. TheCloses #Nline in the PR body would close it on merge anyway; explicit close-on-submit makes the "PR is canonical now" handoff visible immediately rather than days later.
Build tasks.
- Form template changes —
contractor-template/.github/ISSUE_TEMPLATE/hourly-timesheet.ymlandmilestone-invoice.yml:- Entries textarea now seeds a header-bearing row (
Date | Hours | Description/ID | Date | Amount | Description) plus one example row the contractor edits in place. Parser's existing_looks_like_headerrecognises the seed cleanly. - Top markdown block rewritten to describe the draft →
/validate→/submitflow + corrections semantics. - HTML-comment format reminders deferred —
render: textputs the entries in a code block where HTML comments render as literal text; the seeded header row carries the format-reminder role instead. Revisit if/when we droprender: text.
- Entries textarea now seeds a header-bearing row (
- Workflow trigger changes —
contractor-template/.github/workflows/issue-to-pr.ymland engine.github/workflows/process-submission.yml:- Caller drops
issues.opened/issues.edited/issues.reopened. New triggers:issues.labeled(gated on labelsubmit) +issue_comment.created(gated on body starting with/submitor/validateandissue.pull_request == null). - Engine adds a
mode: submit | validateinput (defaultsubmit); both paths share parsing +find_previous_submissionrevision detection. - Validate path: post sentinel-marked result comment (
<!-- submission-validate-result -->), no PR. - Submit path: existing create-PR pipeline, then close+lock issue, then remove
submitlabel.
- Caller drops
- Label bootstrap —
scripts/setup_labels.pyaddssubmit(description: applied to a draft issue to file the submission; auto-removed by the workflow). - Auto-remove
submitlabel after handling — final step of submit mode (regardless of parse outcome), so re-applying the label re-triggers cleanly after a fix. - Close + lock issue on successful submission —
gh issue close(with a PR-handoff comment) +gh issue lock --reason resolvedat the end of the submit-success path. - Validate-result content —
scripts/post_validate_result.pyrenders either a ✅ success comment (table of contract / period / hours / rate / total, currency-aware) reusingenrich_submissionfor totals, or a ❌ error comment (same line-specific format as the parse-error path). Single sentinel; comment is upserted across re-runs. - Tests — 17 tests in
tests/test_post_validate_result.py(render success + error paths, both submission types), 26 intests/test_send_reminders.py(period extraction, period-closed arithmetic across timezones, reminder render, label routing). Trigger-router gates are GitHub Actions YAML expressions — covered by E2E rather than unit tests. Total suite: 211 passing. - E2E on
contractor-engine-test(2026-05-19). Six scenarios passed end-to-end:- Issue #27 opened via form → no PR, no workflow side effects (only label-event runs that skipped on the gate).
/validatewith valid entries → ✅ sentinel comment with computed totals (3 days, 10.5 hours, 525 AUD)./validatewith a bad row → same comment upserted in place with line-specific error./submit→ PR #28 opened, issue closed + locked with handoff comment, stale validate-result comment cleared.- Issue #29 +
submitlabel → bonus parse-fail path validated:parse-errorlabel applied +submitlabel auto-removed (re-applicable). Fix + re-apply → PR #30 opened, both labels cleared, issue closed + locked. - Period reminders:
workflow_dispatchdry-run reported#11: would remind for 2025-11; real run posted the 🔔 reminder; second real run reportedalready reminded(idempotency). Stale pre-Phase-1.5 issues (### Periodsingle dropdown) skipped cleanly withcouldn't extract period. - Two sharp edges surfaced and fixed: reminder workflow needed
contents: readpermission (actions/checkoutrejected the private repo otherwise — fixed in 2f4933a); Month dropdown's em-dash format hurt body editability, dropped in favour of plain01–12(24c2024).
- Documentation — Phase 4's contractor guide pages describe the new flow (
docs/contractor-guide/submit-timesheet.md): fill form → edit body to accumulate entries →/validateto check →/submitto submit.
Period-close reminders.
Once submission is explicit (rather than automatic on issue creation), there's a real risk of RAs leaving drafts open past the period they cover — either because they forgot, were busy, or abandoned an unused draft. A scheduled workflow catches both the legitimate "forgot to submit" case (the high-value one) and surfaces stale drafts cheaply.
Design: a thin caller workflow in each contractor repo runs on schedule: (cron, monthly) and invokes a new engine reusable workflow send-reminders.yml. The engine workflow scans open issues with the timesheet or milestone-invoice label, computes each issue's period from its form metadata, and — for any whose period has ended without a /submit — posts a sentinel-marked reminder comment. Sentinel encodes the period (<!-- reminder:timesheet:2026-04 -->) so the workflow is idempotent across re-runs; it won't double-comment on the same draft for the same period.
-
scripts/send_reminders.py— scans the contractor repo's open issues viagh issue list, filters to submission labels, parses each issue's period from the form metadata (regex over### Year/### Monthsections — no full parse, so malformed-but-period-extractable drafts still get reminded), identifies open drafts where the period has closed, posts the sentinel-marked reminder comment.--dry-runfor log-without-post. - Period-closed semantics. Period closes at the first instant of the next month in the payer's timezone (
fiscal-host.yml.psl_foundation.timezone, default UTC if missing) — consistent with how document dates are computed (§9). Grace period not yet configurable (defaults to 0); add as a--grace-daysflag if friction emerges. -
.github/workflows/send-reminders.yml(engine) —on: workflow_callwithdry_runboolean input. -
contractor-template/.github/workflows/period-reminders.yml— thin caller withon: schedule:(cron0 9 1 * *) +workflow_dispatch(so admins can run on demand or dry-run against a test repo). Onboarding script picks it up automatically via the existingrglobovercontractor-template/. - Reminder comment content. 🔔 header naming the closed period +
/validateand/submitnext-steps + close-if-nothing-to-file advice + period-encoded sentinel<!-- submission-reminder:YYYY-MM -->. - Escalation (optional, deferred). Day-7 admin-@-mention follow-up. Not built; revisit if drafts pile up in practice.
- E2E coverage — verified during the Phase 3c E2E (2026-05-19): dry-run + real + re-run cycle on issue #11 of
contractor-engine-testfor period2025-11. See the Phase 3c E2E checklist above for details.
Open items for this phase:
- Submitter authorization. Default: author-only may run
/submitand/validate. Admin-override (so an admin can submit on behalf of an RA in a pinch) deferred until friction is observed. - Pre-merge edit handling. Once a PR is open, the previous "edit the issue, PR regenerates" behaviour is gone (issue is locked). Corrections go via the PR branch only. Re-evaluate after first real submissions whether a
/regeneratecomment on the PR is worth adding.
Docs site — MkDocs Material on GitHub Pages, deployed via Actions artifact (no gh-pages branch). Public site source lives in docs/; internal runbooks live in notes/.
- Scaffold + landing page (commit 18cd80e) —
mkdocs.yml,docs/index.mdplaceholder ("guide coming soon"), gh-pages branch workflow. MovedEMAIL_SETUP.mdfromdocs/tonotes/so it's not published as part of the public site. - Switched to Pages artifact deploy (commit 4b174ce) —
.github/workflows/docs.ymlusesactions/upload-pages-artifact+actions/deploy-pages;gh-pagesbranch deleted. Repo Pages source set to "GitHub Actions". Site live at https://quantecon.github.io/contractor-payments/. - Contractor guide pages (under
docs/contractor-guide/) — three tutorials shipped with 14 screenshots captured fromcontractor-engine-test(commit 0c9889d):-
submit-timesheet.md— hourly timesheet walk-through, full Phase 3c flow (draft →/validate→/submit); 7 screenshots showing chooser, form, body-edit,/validatesuccess + error (upsert visible via "Last edited by github-actions"),/submit+ close + lock, and the opened PR with inline PNG preview. -
submit-invoice.md— milestone invoice variant. Worked example anchored to milestone 3 / 2026-07-15 / 77000 JPY; 7 screenshots paralleling the timesheet sequence. Covers catch-up submissions and the milestone-ID warning behaviour. -
corrections.md— per-stage correction paths (pre-submit, pre-merge, pre-payment revision, post-payment), plus parse-error recovery and "bot didn't respond" troubleshooting. Text-only. - Index page rewritten to point at the three tutorials with one-paragraph "how the flow works" preamble.
- PR-rendered screenshots (
ts-07+mi-07) have the contractor info column redacted with a black box; test repo settings still contain real PII so a future cleanup task (sanitizecontractor-engine-test/config/settings.yml) will let future captures be unredacted.
-
- Fixed broken doc URLs in
contractor-template/—ISSUE_TEMPLATE/config.yml, both issue templates, andREADME.mdnow point athttps://quantecon.github.io/contractor-payments/...instead of the never-existedblob/main/docs/CONTRACTOR_GUIDE.md. README's "Submitting" section also updated to describe the Phase 3c flow (/validate+/submit) instead of the pre-Phase-3c auto-PR-on-creation flow. - Admin guide — deferred; the admin runbook content can live in
notes/or as a separate non-public section. Decide before flippingtesting_mode. - First real contractor onboarding checklist. Run before / during the first real
onboarding/new-contractor.pyinvocation:- Confirm
vars.PSL_EMAIL+vars.QUANTECON_EMAIL_REVIEWERare still set on theQuantEconorg (gh variable list --org QuantEcon). Both were set during Phase 2 (§9 Email recipient policy); a one-line sanity check before going live. - Confirm all five org-level SMTP secrets are still set on
QuantEcon(SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASSWORD,SMTP_FROM). Set during Phase 2 (notes/EMAIL_SETUP.md). -
Confirm the org-level branch-protection ruleset for— superseded 2026-05-20. Branch protection deferred to post-launch; see Phase 3b §8 "Branch protection oncontractor-*reposmain— DEFERRED" for the decision and the GitHub App migration plan. First-contractor flow ships without protection; admin acknowledges the gap at onboarding time. - Set each contractor's
testing_modedeliberately. The flag is now per-repo (config/settings.yml→notifications.testing_mode), overriding the engine-wide default intemplates/fiscal-host.yml(which staystrueas the fail-safe).false= approval emails go to PSL (production);true/absent = emails stay withvars.QUANTECON_EMAIL_REVIEWERonly. Recommended: onboard intruefor the first cycle, verify the first merge, then set that repo'stesting_mode: false. Going to production is per-repo opt-in — there is no global flip. - Run
onboarding/new-contractor.pywith their YAML (onboarding/contractors/{handle}.ymlbased onexample.yml). Use--dry-runfirst to preview. - After onboarding completes, share the contractor-facing guide link (https://quantecon.github.io/contractor-payments/) and the new repo URL with them via the onboarding email.
- Confirm
- Per-repo production cutover. When a contractor's flow is verified, set
notifications.testing_mode: falsein theirconfig/settings.ymland commit. That repo starts sending real approval emails to PSL; all other repos are unaffected. The engine default in templates/fiscal-host.yml staystrueas the fail-safe. - Onboard a small number of additional real contractors; iterate on friction.
Deferred to a standalone phase because reimbursements are materially more complex than timesheets and invoices: they involve multi-currency receipts (a single trip may produce receipts in 2-3 currencies), receipt storage (an unresolved open question — see §10), ad-hoc authorisation (no pre-existing contract to check against), and tax-category handling that varies by jurisdiction. Bundling these into Phase 1.5 / 2 would have slowed the launch; running them as a post-launch addition lets real Phase 4 contractors stress-test the simpler types first.
Phase 5 build:
- Receipt-storage policy resolved (where receipts live, PII handling, size limits, multi-page) — see §10.
- Multi-currency design — does a single reimbursement carry multiple currencies (per-line-item currency), or is each currency a separate submission? Decision drives both the form shape and the PDF render.
-
scripts/parse_reimbursement_issue.py(or branch inparse_issue.py) — handles line items with date/amount/category/currency/description; validates totals (per-currency if multi-currency); validates category againstconfig/settings.ymlallowed list. -
config/settings.ymlextension — addreimbursement.allowed_categories: [...]per contractor. -
contractor-template/.github/ISSUE_TEMPLATE/reimbursement-claim.yml— the new form (§4.7), updated for multi-currency. -
templates/reimbursement.typ— Typst template (titleQUANTECON REIMBURSEMENT; line-item table; trip-context block; receipts appendix per policy). -
scripts/create_submission_pr.py— extend for the third type. -
onboarding/new-contractor.py— add the multi-select for issue templates. From this phase forward, an admin can configure a payee as reimbursement-only (e.g. one-off speakers, honorarium recipients) or as a full contractor with all three types. Also adds thereimbursement.allowed_categoriesprompt. - End-to-end test against
contractor-engine-test(or a newcontractor-reimbursement-test): submit a reimbursement claim with multiple line items (and multi-currency if that design wins), verify the merge flow.
- SMTP email delivery (currently @-mention only)
- Additional submission types beyond the three planned (none identified yet)
- Centralized contractor / contract data store
qemanager-style admin CLI- Contract lifecycle automation (renewals, end-dates, status tracking)
- Contract PDF generation
git-cryptencryption-at-rest posture- Cross-contractor reporting
- Personnel data plumbing (mailing lists, GitHub team membership, payment-platform exports)
| Decision | Choice | Why |
|---|---|---|
| Repo topology | Per-contractor private repos QuantEcon/contractor-{handle} |
Privacy by construction; future-proof name. |
| Contract / contractor data | Plaintext YAML in each contractor's repo (config/settings.yml + contracts/*.yml) |
Co-located with submissions; admin-edited by hand. |
| Contract ID convention | QE-PSL-YYYY-NNN |
QuantEcon's existing numbering scheme. System accepts any string; onboarding pre-fills this format. |
| Shared logic | Reusable workflows + scripts in QuantEcon/contractor-payments |
Single source of truth. |
| Onboarding | Interactive Python script onboarding/new-contractor.py |
Asks the right questions; populates the repo cleanly. |
| Submission ID | {handle}-{type}-{period} with two semantic suffix schemes: -vN for revisions (supersede), -{LETTER} from B onward for supplementals (additional invoices in the same period). Composable (-B-v2). |
Period-based for readability. Two distinct suffix namespaces let identifiers carry accounting intent (v = supersede previous, letter = additive). Phase 2.5 wires the engine semantics to match. |
| Notification | Email to PSL (Cc admin) + internal GitHub comment | PSL doesn't use GitHub; email is the natural delivery for the approved PDF. Internal comment is operational audit (confirms the email step succeeded). See §8 Phase 2. |
| Email mechanism | Google Workspace SMTP from a QuantEcon service-account mailbox (sender lives in secrets.SMTP_USER/SMTP_FROM — see Email credentials row) |
QuantEcon already owns the Google Workspace; no third-party transactional service needed at this volume (well under Gmail's 2,000/day limit). Switch to Postmark/Mailgun later if deliverability ever becomes an issue. |
| Email recipient policy | Recipient addresses live as GitHub org-level Variables (vars.PSL_EMAIL, vars.QUANTECON_EMAIL_REVIEWER), not in any file in this (public) engine repo |
Engine repo is public for actions/checkout access; literal email addresses in committed files would be harvested for spam. Variables keep recipients private without auth/PAT overhead. |
| Email credentials | GitHub org-level secrets (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) |
Scoped at the org so every contractor repo's reusable-workflow run can read them without per-repo setup. Never in any YAML; never committed. |
| Email recipients & testing | Two-layer testing_mode resolution (most specific wins): (1) contractor repo config/settings.yml → notifications.testing_mode, (2) engine templates/fiscal-host.yml → notifications.testing_mode, (3) hard-coded true fail-safe. While the effective testing_mode is true, mail goes to vars.QUANTECON_EMAIL_REVIEWER only — PSL is never contacted. Recipient addresses themselves are org-level Variables, not in either file. |
Per-repo override lets production and test repos coexist: real contractors opt into PSL delivery with testing_mode: false in their own settings; contractor-engine-test inherits the safe true default with no config. The dangerous action (emailing real PSL) is always an explicit opt-in, never the silence default. Replaces the original single global flip. |
| v1 submission types | Hourly Timesheet + Milestone Invoice + Reimbursement Claim (all three planned architecturally; phased build per §8) | Engine generic across types from day one; per-type build phases keep scope tight. |
| Milestone contract shape | Structured milestones[] schedule with (id, date, amount, description) per row, plus the contractor entering the row at submission time. Parser emits a non-blocking warning if the submitted milestone ID isn't in the contract's schedule. |
Onboarding collects the schedule interactively, so the structured field is free at write-time. Warnings catch typos cheaply without making the engine a hard gate; admin still verifies during PR review. Originally deferred (lightweight notes:-only spec) — flipped 2026-05-18 once Phase 3b's onboarding flow made the structured collection trivial. |
| Submission trigger | Issue is a long-lived draft; PR opens only on an explicit /submit comment or submit label. /validate comment runs the parser in dry-run mode (no PR), reporting parse status + computed totals as a sentinel-marked comment that updates in place across re-runs. Form layer still provides the structured/restricted UI for the initial body seed; HTML-comment format reminders persist through edits. |
Lets RAs accumulate hours across days/weeks inside the issue body itself rather than maintaining an external spreadsheet, surfaces parse errors before commit, and keeps the issue as a working surface rather than a single-shot form drop. PR remains canonical once opened (issue closed + locked on /submit). Decided 2026-05-19; Phase 3c builds it. |
| Reimbursement contract relationship | Reimbursements are contractor-level, not contract-level | RA/staff expenses are ad-hoc, hard to pre-authorize in a contract; authorization happens per-claim via PR review. Reimbursements live in the contractor repo without a contract_id reference. |
| Issue-template seeding | Phase 3 onboarding seeds both Hourly Timesheet and Milestone Invoice unconditionally. Multi-select (incl. Reimbursement) added in Phase 5. | With two templates, all payees get both — multi-select adds friction without benefit. Multi-select lands alongside Reimbursement when the third type makes selectivity meaningful (e.g. reimbursement-only payees). Workflow file is identical across all repos either way — routing is by label, unused branches inert. |
| Engine repo name | QuantEcon/contractor-payments |
Scope grew beyond timesheets; "contractor-payments" pairs naturally with the contractor-{handle} payee repos. Renamed from QuantEcon/timesheets during Phase 1.5 alignment. |
| Ledger in v1 | Yes — one ledger/<contract-id>.yml per contract; one pinned GitHub issue per contract as the consumption surface |
YAML stays the structured source of truth; the auto-updated pinned issue gives contractor + admin a discoverable, notification-driven view of running totals without any new UI. Cheap to maintain (rendered from YAML), no backfill problem. Cross-contractor reporting and dashboards remain post-launch territory (see QuantEcon/admin#5). |
| Encryption at rest | None | Each repo holds one contractor's data; access is naturally scoped. Revisit if a centralized store is later built. |
| Currency | Per-contract field; AUD / USD / JPY in v1 | QuantEcon already has real contractors in all three. Currency lives on each contract; PDF renders ISO code as suffix, no symbols; JPY without decimals. |
| Reviewer-facing artifact | PDF (authoritative) + PNG preview (inline in PR body) | GitHub doesn't render PDFs in PR diffs; images do. PNG embed closes the review loop without leaving the PR; PDF is what the payments manager receives. |
| Fiscal-host identity & policy file | templates/fiscal-host.yml (engine repo) — renamed from branding.yml once it grew beyond addresses to also hold the document-date timezone and email notification recipients. |
"Fiscal host" precisely names PSL's relationship to QuantEcon (sponsored-project / fiscal-sponsorship context). Single source of truth across all contractor repos. |
| Document-date timezone | Payer's locale (psl_foundation.timezone in fiscal-host.yml, default America/New_York) |
Paperwork lines up with payer's books; contractor locale irrelevant. UTC fallback if unset. |
| Contractor address | Optional contractor.address in settings.yml (multi-line) |
Recommended for tax-invoice compliance; renders only when populated. No bank/tax-ID data ever — that policy carries through from earlier. |
| External Actions | None on the financial-data path | Inherited from source issue. |
- Admin handle(s). Just
mmcky, or also a team handle? Org settings — reusable workflows in private repos.✅ Resolved during Phase 3a (commit 461d24a): setactions/permissions/access=organizationon the engine repo, and flipped engine repo visibility to public soactions/checkoutworks without a PAT. See §9 Email recipient policy for the recipient-address handling that the public-visibility decision drove.- Actions on private repos / runner-minute budget. Confirm enabled + headroom.
SMTP credentials for the QuantEcon service-account mailbox✅ Resolved: all five org secrets (SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASSWORD,SMTP_FROM) set onQuantEcon. Sending identity ispayments@quantecon.org(alias ofadmin@quantecon.org); authentication uses a dedicated Google app password on admin@. Unblocks the full Phase 2 E2E.Org-level recipient variables.✅ Resolved:vars.PSL_EMAIL(PSL Foundation contact) andvars.QUANTECON_EMAIL_REVIEWER(QuantEcon human reviewer/approver, Cc) are set as org-level Variables onQuantEcon. Visibility: Private repositories.- Real-name surfacing. Mitigation for the payments manager being unable to map GitHub handles → real names: every PDF and notification email surfaces the contractor's real name from
settings.yml. - Broken doc URLs in
contractor-template/.contractor-template/.github/ISSUE_TEMPLATE/config.ymland the "Need help?" link inside bothhourly-timesheet.ymlandmilestone-invoice.ymlpoint atblob/main/docs/CONTRACTOR_GUIDE.md— a path that never existed and won't, since the guide is now a published MkDocs site. Repoint athttps://quantecon.github.io/contractor-payments/contractor-guide/submit-timesheet/(and the invoice equivalent) once those pages land in Phase 4. Tracked in the Phase 4 task list. - Receipt storage for Reimbursement Claims. Gates Phase 5 (post-launch). Decision spans: where receipts physically live (committed PDFs in
receipts/<period>/? GitHub issue attachments? external store?), how PII is handled (card numbers, addresses on the receipt itself), file size and multi-page limits, and how receipts surface in the rendered PDF (inline thumbnails? appendix pages? references only?). The reimbursement form schema (§4.7) and the merge-processing PDF render both depend on this. - Multi-currency for Reimbursement Claims. Also gates Phase 5. A single trip may produce receipts in 2-3 currencies. Decision: per-line-item currency (one submission spans multiple currencies) vs one-currency-per-submission (file separate claims). Drives the form shape, the parser, the PDF render, and the ledger schema for reimbursements.
- Parser header-row heuristic is too greedy (Phase 3c E2E finding, 2026-05-19).
_looks_like_headerinscripts/parse_issue.pyskips any row whose first field contains the substringdate,day,when,id, ormilestone— caught us when a contractor typedbad-date | 2 | oopsas a data row and the parser silently skipped it, producing a false/validatesuccess. Fix: tighten the heuristic to recognise the actual seeded header row (Date | Hours | Description/ID | Date | Amount | Description) rather than matching on keyword substrings. Small change + new test; non-blocking because the contractor would notice that the bad row's hours/amount didn't appear in the/validatetotals.
- All contractor repos are private. The engine repo could later be made public as a reference implementation; by design it holds no data.
- No third-party GitHub Actions on any financial-data path. Only
actions/checkoutandactions/setup-pythonfrom first-partyactions/*. - Branch protection on
mainfor every contractor repo: DEFERRED (no protection in place as of 2026-05-20) — original ruleset-based spec no longer works after GitHub UI changes removed the GitHub Actions integration as a bypass actor; migration path is a dedicated GitHub App for the engine push. See §8 Phase 3b "Branch protection onmain— DEFERRED" andnotes/ADMIN_RUNBOOK.md. - GitHub Secrets only for credentials the workflow needs. From Phase 2: SMTP credentials (
SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASSWORD,SMTP_FROM) live as org-level secrets onQuantEcon, so every contractor repo's reusable-workflow run reads them without per-repo setup. Never in any YAML; never committed. - Email content is sensitive. Approval emails carry the contractor's real name, contract ID, period, amount, and an attached PDF with the same data. In transit: TLS via SMTP submission port 587. At rest: in the PSL recipient's inbox + Cc on the QuantEcon admin mailbox. We accept this — the recipient is the fiscal host, the email is what triggers payment, and there's no way to deliver value to PSL without the data being present at the receiving end. The
notifications.testing_modeflag keeps PSL off the recipient list until a repo is explicitly cut over. It resolves per-repo — the contractor repo'sconfig/settings.ymloverrides the engine-wide default intemplates/fiscal-host.yml, which staystrue(fail-safe); a repo that configures nothing never emails PSL (see §9 "Email recipients & testing"). - No email addresses in committed files. The engine repo is public to allow
actions/checkoutfrom contractor repos. Recipient addresses (vars.PSL_EMAIL,vars.QUANTECON_EMAIL_REVIEWER) and sender credentials (secrets.SMTP_*) are stored only as GitHub org-level Variables / Secrets — never committed. Git history was scrubbed of pre-policy literal addresses viagit filter-repoat the introduction of this rule. - Python deps minimal: stdlib +
pyyaml. - No bank accounts, tax IDs, or other payment credentials in any repo. Reference an external store (1Password, etc.) by stable ID if needed.
- Local working dirs:
/Users/mmcky/work/quantecon/contractor-payments/(engine, this repo)/Users/mmcky/work/quantecon/contractor-engine-test/(Phase 1/2 test repo — pre-Phase-3b clone location)contractors/contractor-{handle}/(relative to the engine repo root — onboarding script clones new contractor repos here; gitignored). Co-locating these under the engine repo means admin tools can chdir into a contractor checkout without leaving the engine workspace.
- Local toolchain:
typst(brew install typst), Python 3.12+,ghCLI,pypdf(dev — used to assert single-page output in worst-case tests). - Running the engine locally:
- Tests:
pytest tests/(80 cases, ~0.1s). - Render a PDF from a submission YAML:
python -m scripts.generate_pdf --submission ... --settings ... --templates templates --output .... - Engine scripts assume the module-form invocation (
python -m scripts.create_submission_pr ...) becausecreate_submission_primports fromscripts.generate_pdf.
- Tests:
- This
PLAN.mdis the source of truth for the project plan. Update it in PRs as decisions evolve.