Skip to content

Latest commit

 

History

History
1083 lines (840 loc) · 101 KB

File metadata and controls

1083 lines (840 loc) · 101 KB

QuantEcon Contractor Payments — Implementation Plan

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


At a glance

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: true confirmed working)
  • Phase 2.5 — Revision + Independent-invoice handling (full E2E verified on contractor-engine-test: revision via -vN with supersede semantics, plus same-period -B independent invoices; both paths through ledger, PDF banner, email, cross-comments)
  • Phase 3b — Onboarding script for new contractor repos (E2E verified on a fresh contractor-mmcky repo: 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 on contractor-engine-test 2026-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_mode opt-in to production; see §9 "Email recipients & testing") ← current target
  • Phase 5 — Reimbursement Claim engine (post-launch)

Contents

  1. Goals
  2. Architectural decision — per-contractor private repos
  3. Repository topology
  4. Inside a contractor repo
  5. Onboarding script
  6. v1 scope
  7. Workflow in practice
  8. Build phases
  9. Resolved decisions
  10. Open items
  11. Security posture
  12. Working notes

1. Goals

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.

2. Architectural decision — per-contractor private repos

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.


3. Repository topology

Two repos:

QuantEcon/contractor-payments                  ← engine: workflows, scripts, Typst, contractor-template, onboarding script
QuantEcon/contractor-{handle}         ← per-contractor private repo

3.1 QuantEcon/contractor-payments (this repo) — the engine

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)

3.2 QuantEcon/contractor-{handle} — per contractor, private

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 (mmcky initially) — Admin.
  • The payments manager — Read (so they can see PDFs and get notifications).

4. Inside a contractor repo

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.

4.1 config/settings.yml — contractor identity + routing

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: mmcky

Written 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.yml as the single source of truth across every contractor repo (§9). Per-contractor settings.yml only 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 in fiscal-host.yml.notifications.psl_to.

4.2 contracts/{contract-id}.yml — contract terms

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 (PSL for PSL Foundation, others as needed)
  • YYYY — contract year
  • NNN — 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.

4.3 Submission forms — overview

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).

4.4 .github/ISSUE_TEMPLATE/hourly-timesheet.yml — Hourly Timesheet form

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:

  1. 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.
  2. 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.
  3. Month (dropdown, required) — static list 01 through 12. Parser is lenient (accepts 5, 05, and legacy 05 — May from 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.
  4. 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.
  5. Additional notes (textarea, optional) — free text.
  6. 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: true

A 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 screenshots

Parser tolerancesparse_issue.py accepts common variations:

  • Date formats: YYYY-MM-DD canonical; also accept YYYY/MM/DD and DD-MM-YYYY if the month is unambiguous.
  • Hour-unit suffixes stripped: 4.5, 4.5h, 4.5 hrs all parse to 4.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).

4.5 Submission validation and failure handling

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-error label. 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: opened and issues: edited only.
  • 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-error label 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).

4.6 .github/ISSUE_TEMPLATE/milestone-invoice.yml — Milestone Invoice form

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:

  1. Contract (dropdown, required) — populated with the contractor's active milestone contract IDs (hourly contracts excluded).
  2. Year + Month (dropdowns, both required) — same two-dropdown pattern as §4.4. Parser combines them to YYYY-MM.
  3. 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. The ID is the milestone number from the contract's schedule (e.g. 3 for "Payment 3 of 6") — the contractor reads it off the contract's notes (§4.2) and types it in. Currency is fixed by the contract.
  4. Additional notes (textarea, optional) — free text.
  5. Confirmation (checkbox, required) — single ack.

Parser tolerances — same lenient rules as timesheet rows:

  • Date formats: YYYY-MM-DD canonical; YYYY/MM/DD and DD-MM-YYYY accepted 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 Period is 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 ID within 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).

4.7 .github/ISSUE_TEMPLATE/reimbursement-claim.yml — Reimbursement Claim form

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:

  1. 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).
  2. 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's config/settings.yml allowed list.
  3. Currency (dropdown, required) — ISO 4217 code; same supported list as contracts (AUD | USD | JPY in v1, extensible). One currency per submission.
  4. 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).
  5. Trip / project context (textarea, optional) — free text. Useful for trips where line items don't individually justify their purpose.
  6. Receipts — see "Receipt storage" below.
  7. 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.

4.8 Generic submission YAML shape

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 submission

This 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.


5. Onboarding — onboarding/new-contractor.py

A single interactive Python script. Stdlib argparse + pyyaml + subprocess to gh. Run from a clone of QuantEcon/contractor-payments.

What it does

  1. Prompts for (with reasonable defaults where applicable):
    • GitHub handle of the new contractor
    • Real name
    • Email
    • 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
  2. Creates QuantEcon/contractor-{handle} as a private repo.
  3. Seeds the repo from contractor-template/, substituting prompted values into config/settings.yml, README.md, CODEOWNERS, and the contract YAML. Both hourly-timesheet.yml and milestone-invoice.yml issue 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).
  4. Generates contracts/{contract-id}.yml from the prompted contract details.
  5. Adds the contractor (Write), admin (Admin), and payments manager (Read) as collaborators via gh api.
  6. Sets branch protection on main (PR required, 1 review).
  7. Creates the workflow labels via gh label create (idempotent — skips any that already exist). Required because GitHub Issue Forms silently drop labels: values that don't exist on the repo, which would break the workflow's label-based routing:
    • timesheet — applied by the Hourly Timesheet form
    • milestone-invoice — applied by the Milestone Invoice form
    • pending-review — applied by both submission forms
    • parse-error — applied by the workflow on parse failure
    • submission — applied by the workflow when opening the submission PR
    • processed — 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
  8. Pushes the initial commit.
  9. 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.

What it does not do

  • 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 contractor if you tag the repos, or list contractor-* repos via gh repo list).
  • No batch operations or template re-sync — when workflows in QuantEcon/contractor-payments change, contractor repos that reference them via reusable workflows pick up the change automatically. Files copied from contractor-template/ are only re-synced manually if needed.

Implementation notes

  • 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 gh CLI subprocess calls; no Python GitHub libraries.

6. v1 scope

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.

7. Workflow in practice

7.1 Contractor submitting a timesheet

  1. Contractor opens github.com/QuantEcon/contractor-{theirhandle} (bookmarked).
  2. Issues → New Issue → 📋 Hourly Timesheet → fill out form → submit.
  3. issue-to-pr.yml parses the form, writes the submission YAML in submissions/<YYYY-MM>/, renders the "PENDING REVIEW" PDF and a PNG preview in generated_pdfs/<YYYY-MM>/, opens a PR with the PNG embedded inline in the description.
  4. CODEOWNERS auto-requests review from admin. Contractor + admin get notifications.
  5. Reviewer sees the PNG inline in the PR; clicks through to the PDF if they want the authoritative artifact.
  6. Corrections: contractor edits the PR branch directly, or admin requests changes via PR review.
  7. Admin approves and merges.

7.2 On merge

  1. process-approved.yml identifies the merged submission.
  2. Updates ledger/<contract-id>.yml with the new totals.
  3. Re-renders the PDF with approval metadata (approved_by, approved_date set), replacing the "PENDING REVIEW" version that the PR carried.
  4. Comments on the now-closed issue: @{payments_manager} Approved — {real name} — PDF: <blob URL>.
  5. Applies processed label.

7.3 Admin onboarding a new contractor

  1. python onboarding/new-contractor.py — answer the prompts.
  2. Script creates the repo, seeds it, adds collaborators, pushes. Prints the URL.
  3. Admin sends the repo URL + the docs site (https://quantecon.github.io/contractor-payments/) to the new contractor.

7.4 Admin renewing a contract

  1. In the contractor's repo, copy contracts/{old-contract-id}.yml to contracts/{new-contract-id}.yml.
  2. Edit dates / rate / status as needed.
  3. Mark the old contract status: ended.
  4. Edit .github/ISSUE_TEMPLATE/hourly-timesheet.yml — add the new contract ID to the Contract dropdown options, remove the ended one if appropriate.
  5. 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.


8. Build phases

Phase 0 — Planning (in progress)

  • Create QuantEcon/contractor-payments
  • Tighten PLAN.md to 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)

Phase 1 — Timesheets engine in a single test repo ✅

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 -vN collision 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-written config/settings.yml and one contracts/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) → -v2 suffix applied

Project scaffold:

  • pyproject.toml, .gitignore, tests/ (80 unit tests passing across three files)

Phase 1.5 — Milestone Invoice engine ✅

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 extensiontype: 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 parses ID | YYYY-MM-DD | amount | description rows. 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 (title QUANTECON 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 both timesheet and milestone-invoice labels 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/timesheetsQuantEcon/contractor-payments. Engine repo URL refs + local clone path updated.

Phase 3a — Reusable workflows (pulled forward to stop sync drift) ✅

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 accessactions/permissions/access set to organization on QuantEcon/contractor-payments so other org repos can call its reusable workflows.
  • Engine repo visibility flipped to public — required for actions/checkout on the engine repo from a contractor repo's workflow (the caller's GITHUB_TOKEN is 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.ymlon: workflow_call. Two checkouts (contractor repo at working dir, engine at ./engine). Scripts run with PYTHONPATH=engine and --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 the github.event.issue context, applies the label-gate predicate, secrets: inherit for 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-test via the new thin caller; workflow ran cleanly through the reusable workflow, opened PR #14 with correct YAML + PDF + PNG.

Phase 2 — Merge processing + email notify

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 inscripts/finalize_approval.py (4619305). Stamps the submission YAML with status: 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 to ledger/<contract-id>.yml. Branches by type: hourly writes submissions[] + hours_to_date; milestone writes claims[] + claims_count. Idempotent against duplicate submission_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 via contract.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) when ledger_issue is absent from the contract YAML.

Email delivery to PSL:

  • scripts/notify_email.py (60bdd9c) — composes plain-text email body + PDF attachment, sends via stdlib smtplib + STARTTLS. Subject: [QuantEcon] {Type} approved — {Real Name} — {Period} — {Amount} {Currency}. Recipients: vars.PSL_EMAIL (To) + vars.QUANTECON_EMAIL_REVIEWER (Cc) when testing_mode: false; vars.QUANTECON_EMAIL_REVIEWER only when testing_mode: true. Reply-To set to secrets.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_mode flag). 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 processed label. The comment runs last and reflects the email outcome via the JSON summary that notify_email --output-summary writes for it.
  • .github/workflows/process-approved.yml (c190514 + 59102f5 fix) — engine reusable workflow on workflow_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 in contractor-template/.github/workflows/process-approved.yml; thin caller filters to merged PRs with the submission label.
  • GitHub org-level secrets — all five SMTP secrets (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) set on QuantEcon (Private repositories visibility). SMTP_USER=admin@quantecon.org (the authenticated mailbox), SMTP_FROM=payments@quantecon.org (an alias of admin@), SMTP_PASSWORD is 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-test for contract QE-IUJ-2025-002 — pinned, locked, label ledger, body pre-populated with the empty-state markdown. Issue number written into contracts/QE-IUJ-2025-002.yml as ledger_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 without SMTP_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_REVIEWER mailbox (testing_mode keeps PSL off), audit comment posts on the issue, processed label appears on the PR.

🛑 BREAK — testing phase ✅

Once Phase 3a + Phase 2 implemented, the discipline was to stop and test thoroughly before continuing. During this phase:

  • notifications.testing_mode stayed true — the mailbox referenced by vars.QUANTECON_EMAIL_REVIEWER received all approval emails; vars.PSL_EMAIL was 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 → processed label applied.
  • testing_mode: true is retained through Phase 2.5 and Phase 3b. The flip to testing_mode: false happens at the start of Phase 4.

The testing surfaced an operational design question (post-merge corrections), captured and addressed in Phase 2.5 below.


Phase 2.5 — Revision + Supplemental handling ✅

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_of metadata) → admin merged → email landed with REVISION approved subject → 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 -B flow: fresh issue #21 for the same period → engine detected collision but no revision target → assigned -B suffix 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 open not 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 $ADMIN placeholder, substituted by Phase 3b onboarding); deployed literally to contractor-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_by from 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 → -B for 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 (the v carries 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.reopened event, 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 -vN suffix; engine proceeds through the normal pipeline.
  • Revision YAML metadata. Stamp supersedes: <previous-id> and revision_of: <original-id> (chain anchor for revision-of-revision cases). On the approved-then-superseded original on main: post-merge update adds superseded_by: <new-id> and status: superseded.
  • Revision PDF banner. When supersedes is 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. Net claims_count / submissions_count stays 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.opened and assign next available letter suffix from B onward (skipping any already in submissions/). 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.py suffix 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.py branches on supersedes metadata (revision path replaces; otherwise appends — covers both originals and -B/-C invoices uniformly).
  • update_ledger_issue.py renders 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, -B then revision of -B, multiple -B/-C independent 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 -B side 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.


Phase 3b — Onboarding script for new contractor repos

  • onboarding/new-contractor.py per §5 — seeds both Hourly Timesheet and Milestone Invoice templates unconditionally; creates the contractor repo; adds collaborators; creates labels via scripts/setup_labels.py; opens the pinned ledger issue and wires ledger_issue: <N> back into the contract. Flags-with-prompt-fallback, plus --config <yaml> for the full record as a reviewable artifact (admin-private files under onboarding/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 the github-actions integration. Reassessed 2026-05-20 when preparing for the first real contractor; the design as written no longer works:
      • Legacy/classic per-repo branch protectionenforce_admins: true/false both break the workflow. true blocks the [skip ci] bot push from process-approved.yml (ledger + PDF re-stamp); false only exempts human admins, the bot is still rejected with GH006: 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-actions system 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.
    • 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:write permissions, installed on contractor-* repos. The workflow uses actions/create-github-app-token to mint a short-lived push token; this token is owned by the App's bot identity (distinct from the built-in github-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.py design). Pins it to the repo's Issues tab. Locks it from comments. Writes the issue number back into contracts/<contract-id>.yml as ledger_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-mmcky from 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.yml updated ledger/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 emailed mmcky@quantecon.org (testing_mode=true held PSL off). Test repo deleted after verification.

Phase 3c — Deferred submission: issue-as-draft + /submit + /validate

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.

  1. 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.

  2. PR creation is explicit, not automatic. Two triggers, both supported:

    • Primary: comment /submit on the issue.
    • Secondary: apply the submit label.

    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).

  3. /validate for pre-flight checks. A /validate comment 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 /validate updates 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. The Closes #N line 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 changescontractor-template/.github/ISSUE_TEMPLATE/hourly-timesheet.yml and milestone-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_header recognises the seed cleanly.
    • Top markdown block rewritten to describe the draft → /validate/submit flow + corrections semantics.
    • HTML-comment format reminders deferred — render: text puts 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 drop render: text.
  • Workflow trigger changescontractor-template/.github/workflows/issue-to-pr.yml and engine .github/workflows/process-submission.yml:
    • Caller drops issues.opened / issues.edited / issues.reopened. New triggers: issues.labeled (gated on label submit) + issue_comment.created (gated on body starting with /submit or /validate and issue.pull_request == null).
    • Engine adds a mode: submit | validate input (default submit); both paths share parsing + find_previous_submission revision 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 submit label.
  • Label bootstrapscripts/setup_labels.py adds submit (description: applied to a draft issue to file the submission; auto-removed by the workflow).
  • Auto-remove submit label 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 submissiongh issue close (with a PR-handoff comment) + gh issue lock --reason resolved at the end of the submit-success path.
  • Validate-result contentscripts/post_validate_result.py renders either a ✅ success comment (table of contract / period / hours / rate / total, currency-aware) reusing enrich_submission for 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 in tests/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).
    • /validate with valid entries → ✅ sentinel comment with computed totals (3 days, 10.5 hours, 525 AUD).
    • /validate with 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 + submit label → bonus parse-fail path validated: parse-error label applied + submit label auto-removed (re-applicable). Fix + re-apply → PR #30 opened, both labels cleared, issue closed + locked.
    • Period reminders: workflow_dispatch dry-run reported #11: would remind for 2025-11; real run posted the 🔔 reminder; second real run reported already reminded (idempotency). Stale pre-Phase-1.5 issues (### Period single dropdown) skipped cleanly with couldn't extract period.
    • Two sharp edges surfaced and fixed: reminder workflow needed contents: read permission (actions/checkout rejected the private repo otherwise — fixed in 2f4933a); Month dropdown's em-dash format hurt body editability, dropped in favour of plain 0112 (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 → /validate to check → /submit to 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 via gh issue list, filters to submission labels, parses each issue's period from the form metadata (regex over ### Year / ### Month sections — 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-run for 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-days flag if friction emerges.
  • .github/workflows/send-reminders.yml (engine) — on: workflow_call with dry_run boolean input.
  • contractor-template/.github/workflows/period-reminders.yml — thin caller with on: schedule: (cron 0 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 existing rglob over contractor-template/.
  • Reminder comment content. 🔔 header naming the closed period + /validate and /submit next-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-test for period 2025-11. See the Phase 3c E2E checklist above for details.

Open items for this phase:

  • Submitter authorization. Default: author-only may run /submit and /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 /regenerate comment on the PR is worth adding.

Phase 4 — Docs + first real contractors

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.md placeholder ("guide coming soon"), gh-pages branch workflow. Moved EMAIL_SETUP.md from docs/ to notes/ so it's not published as part of the public site.
  • Switched to Pages artifact deploy (commit 4b174ce) — .github/workflows/docs.yml uses actions/upload-pages-artifact + actions/deploy-pages; gh-pages branch 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 from contractor-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, /validate success + 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 (sanitize contractor-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, and README.md now point at https://quantecon.github.io/contractor-payments/... instead of the never-existed blob/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 flipping testing_mode.
  • First real contractor onboarding checklist. Run before / during the first real onboarding/new-contractor.py invocation:
    • Confirm vars.PSL_EMAIL + vars.QUANTECON_EMAIL_REVIEWER are still set on the QuantEcon org (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 contractor-* repossuperseded 2026-05-20. Branch protection deferred to post-launch; see Phase 3b §8 "Branch protection on main — 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_mode deliberately. The flag is now per-repo (config/settings.ymlnotifications.testing_mode), overriding the engine-wide default in templates/fiscal-host.yml (which stays true as the fail-safe). false = approval emails go to PSL (production); true/absent = emails stay with vars.QUANTECON_EMAIL_REVIEWER only. Recommended: onboard in true for the first cycle, verify the first merge, then set that repo's testing_mode: false. Going to production is per-repo opt-in — there is no global flip.
    • Run onboarding/new-contractor.py with their YAML (onboarding/contractors/{handle}.yml based on example.yml). Use --dry-run first 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.
  • Per-repo production cutover. When a contractor's flow is verified, set notifications.testing_mode: false in their config/settings.yml and commit. That repo starts sending real approval emails to PSL; all other repos are unaffected. The engine default in templates/fiscal-host.yml stays true as the fail-safe.
  • Onboard a small number of additional real contractors; iterate on friction.

Phase 5 — Reimbursement Claim engine + multi-select onboarding (post-launch)

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 in parse_issue.py) — handles line items with date/amount/category/currency/description; validates totals (per-currency if multi-currency); validates category against config/settings.yml allowed list.
  • config/settings.yml extension — add reimbursement.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 (title QUANTECON 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 the reimbursement.allowed_categories prompt.
  • End-to-end test against contractor-engine-test (or a new contractor-reimbursement-test): submit a reimbursement claim with multiple line items (and multi-currency if that design wins), verify the merge flow.

v2+ (future, in scope of this PLAN if needed)

  • SMTP email delivery (currently @-mention only)
  • Additional submission types beyond the three planned (none identified yet)

Extracted to the broader admin infrastructure issue (not v2 of timesheets)

  • Centralized contractor / contract data store
  • qemanager-style admin CLI
  • Contract lifecycle automation (renewals, end-dates, status tracking)
  • Contract PDF generation
  • git-crypt encryption-at-rest posture
  • Cross-contractor reporting
  • Personnel data plumbing (mailing lists, GitHub team membership, payment-platform exports)

9. Resolved decisions

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.ymlnotifications.testing_mode, (2) engine templates/fiscal-host.ymlnotifications.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.

10. Open items

  • Admin handle(s). Just mmcky, or also a team handle?
  • Org settings — reusable workflows in private repos. ✅ Resolved during Phase 3a (commit 461d24a): set actions/permissions/access=organization on the engine repo, and flipped engine repo visibility to public so actions/checkout works 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 on QuantEcon. Sending identity is payments@quantecon.org (alias of admin@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) and vars.QUANTECON_EMAIL_REVIEWER (QuantEcon human reviewer/approver, Cc) are set as org-level Variables on QuantEcon. 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.yml and the "Need help?" link inside both hourly-timesheet.yml and milestone-invoice.yml point at blob/main/docs/CONTRACTOR_GUIDE.md — a path that never existed and won't, since the guide is now a published MkDocs site. Repoint at https://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_header in scripts/parse_issue.py skips any row whose first field contains the substring date, day, when, id, or milestone — caught us when a contractor typed bad-date | 2 | oops as a data row and the parser silently skipped it, producing a false /validate success. 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 /validate totals.

11. Security posture

  • 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/checkout and actions/setup-python from first-party actions/*.
  • Branch protection on main for 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 on main — DEFERRED" and notes/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 on QuantEcon, 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_mode flag keeps PSL off the recipient list until a repo is explicitly cut over. It resolves per-repo — the contractor repo's config/settings.yml overrides the engine-wide default in templates/fiscal-host.yml, which stays true (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/checkout from 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 via git filter-repo at 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.

12. Working notes

  • 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+, gh CLI, 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 ...) because create_submission_pr imports from scripts.generate_pdf.
  • This PLAN.md is the source of truth for the project plan. Update it in PRs as decisions evolve.