goal #79 · design doc · companion to the design directions · 2026-07-28
How the app gets its data and configuration. The direction you named — Gilead-BioStats/workr pipelines with config saved to a GitHub repo — is not just viable, it's nearly assembled: every mechanism below exists in-tree today. This doc states the framework in four layers, shows where Domains bind to it, inventories what's real versus aspirational, and puts the seams on the table as decisions.
The core finding
A project folder and a published snapshot are the same shape. og_run() on a laptop writes the identical static tree (_index.json, status.json, manifest.csv, output/…) that the GitHub Actions lane publishes to Pages — and the deploy workflow literally ships a branch's root, verbatim, after asserting exactly those files exist. Local runs are the dev loop; Actions runs are the acceptance path (D1); the app can't tell them apart and never needs to.
Architecture
config/study-config.yaml (identity + domain registry) · config/data-config.yaml (where inputs live) · config/packages.yaml + manifest.csv / rproject.toml (SHA-pinned environment) · workflows/{phase}/ — workr YAMLs materialized from each package's inst/workflow/ at the pinned SHA · input/ data. One study, one repo, one approval trail (the hub#131 principle, generalized).
▼ GitHub Actions on schedule / dispatch — or og_run() locally
MakeWorkflowList() loads and sorts a phase directory; RunWorkflows() / RunProject() execute with shared state flowing forward; RunStep() resolves every param by one five-rule lookup. Storage is a two-hook contract — LoadData / SaveData on lConfig — with fs_* (local) and gh_* (GitHub) implementations already written.
▼ writes a Project Snapshot
ps-NNN/: output/{phase}/{workflow}/{artifact}.csv + status.json + manifest.csv + reports.json, indexed by snapshots.json and _index.json. Published by pushing the branch to Pages — the branch is the artifact; there is no build step and no server.
▼ fetch only — no API, no compute
Gallery, monitor, snapshot compare, and the provenance chip are all views over the snapshot tree. Provenance is not a feature bolted on: it's the snapshot's own metadata (snapshot_id, input_data_version, manifest SHAs, run status) surfaced in chrome.
Engine anatomy
A workflow is three YAML keys. meta is identity plus arbitrary study-tunable values; spec declares the input domains and columns (validated before the run, and the hook that tells LoadData what to fetch); steps is an ordered list of {name, output, params}. The whole config-as-code story rests on one mechanism — RunStep() resolves each string param through five rules in order:
| # | Rule | Effect |
|---|---|---|
| 1 | "lMeta" / "lData" / "lSpec" | pass the whole object |
| 2 | name found in meta | study config flows into the function call — thresholds, group levels, widget settings |
| 3 | name found in lData | pipeline state flows forward — a prior step's output by name |
| 4 | looks like expr(…) | parsed as an expression |
| 5 | otherwise | literal string |
Rule 2 is the payoff: changing a KRI's threshold, a chart's grouping, or a flag scheme is a meta edit in the study repo — a reviewable one-line PR, no code. gsm.kri's kri0001.yaml carries Threshold: -2,-1,2,3, GroupLevel: Site, AccrualThreshold: 30 in meta; a chart workflow carries the entire safetyGraphics-shaped settings object the same way:
# gsm.safety/inst/workflow/3_reports/safety_histogram.yaml (abridged)
meta:
Type: Report
ID: safety_histogram
domains: # logical slot -> pipeline domain
labs: Mapped_LB
widgetName: safetyHistogram
widgetSettings: # the chart's settings object, verbatim
id_col: subjid
measure_col: measure
value_col: value
group_by: sex
spec:
Mapped_LB:
subjid: {type: character}
measure: {type: character}
value: {type: numeric}
steps:
- {output: InitializedWidget, name: list,
params: {data: Mapped_LB, settings: widgetSettings}}
- {output: strOutputDir, name: getwd}
- {output: Report, name: gsm.safety::RenderSafetyChartsWidget,
params: {lInitialized: InitializedWidget, strWidgetName: widgetName,
strOutputDir: strOutputDir, strOutputFile: ID}}
Every chart workflow is this same three-step wrapper: collect {data, settings}, name an output dir, render. meta.domains is currently declarative only — the natural hook for the app's readiness panel (D-APP6) and domain wiring (below).
Domains × framework
The two launch domains from the design directions bind cleanly to existing workflow families — each domain names the phases it runs and the chart library that renders it. The registry lives in study config (D-APP7), which makes "add a domain" a study-repo PR:
# config/study-config.yaml (proposed)
study:
id: DEMO-301
label: "DEMO-301 — Safety Review"
domains:
safety:
label: Safety
charts: safety.viz # renderer bundle
workflows: [1_mappings, 3_reports] # gsm.mapping + gsm.safety
rbqm:
label: RBQM
charts: gsm.viz # widget bundle
workflows: [1_mappings, 2_metrics, 3_reporting, 4_modules]
# gsm.mapping + gsm.kri + gsm.reporting
| Domain | Workflow source (inst/workflow) | Outputs consumed by the app | Charts |
|---|---|---|---|
| Safety | gsm.mapping 1_mappings · gsm.safety 3_reports (9 chart workflows) | Mapped_LB/AE/SUBJ + rendered widget HTML (reports.json) | safety.viz (11 renderers) |
| RBQM | gsm.mapping 1_mappings · gsm.kri 2_metrics (32 metric YAMLs) + 4_modules · gsm.reporting 3_reporting | Analysis_*, Reporting_*, Flags_* CSVs | gsm.viz widgets (7 modules) |
Both domains share 1_mappings — mapping is study-level, domains are lenses over the mapped data. That's also why the readiness panel is per data domain (LB, AE, SUBJ), not per app Domain.
The study repo
demo-301/
├── config/
│ ├── study-config.yaml # identity + domain registry (proposed)
│ ├── data-config.yaml # domain -> input location overrides
│ └── packages.yaml # human-edited package list
├── manifest.csv # org,package,version,repository,url,sha (generated)
├── rproject.toml # rv-format, SHA-pinned (generated)
├── workflows/ # materialized from packages at pinned SHAs
│ ├── 1_mappings/ 2_metrics/ 3_reporting/ 3_reports/ 4_modules/
├── input/ # study data (synthetic for the demo)
├── .github/workflows/
│ ├── run-pipeline.yaml # schedule + dispatch -> new ps-NNN
│ ├── create-snapshot.yaml
│ └── build-site.yaml # publish branch root -> Pages
└── ps-NNN/… + snapshots.json # on the publish branch: the app's whole backend
Everything above the .github/ line already has a producer: og_init() scaffolds the folder, workr's pkgManifest() writes the pins and pulls workflows/ from each package at the pinned SHA (the gsm.library pattern), and the three Actions exist in open.gismo today. The forkable-K3 increment is essentially joining the two halves that already work: run-study.yaml's install-and-run with build-site.yaml's validate-and-publish. hub#131 asks the same questions for the CSR study repo — name/layout, engine pinning, PR previews — and the answers should be shared: "set up a study repo" must be a command, not an artisanal clone; og_init() plus an Actions template is that command.
Ground truth
Read from source this morning (workr v1.0.0 at ~/Documents/github/workr, both open.gismo lines, gsm.kri/gsm.safety workflow dirs, the four Actions). The framework holds; these are the seams a plan rewrite has to close rather than discover later:
| Seam | State | What's true today |
|---|---|---|
| engine fork | unmerged | The og_*/fs_* local-first engine (v0.2.0) lives only on feat/local-first-prototype; dev (v0.1.0) has the gh_* lane only. Which is the base is a decision, not a fact. |
| artifact paths | 3-way split | gh_SaveData writes output/{wf}/… (no phase segment); the local writer and design.md write output/{phase}/{wf}/…. One contract must win. |
| phase naming | divergent | gsm.safety uses 3_reports; gsm.reporting/open.gismo use 3_reporting. Literal-path loading means the mismatch silently yields an empty phase. |
| status.json | inferred | Local lane derives status from file presence — a step that ran and produced nothing is indistinguishable from one that never ran; failed/error are unreachable. The Actions lane records incrementally but races on concurrent runs. |
| log.json | stub | The Action writes an empty workflows: {}; the local lane writes nothing. If the app shows logs, something must actually produce them. |
| snapshot ids | works | ps-NNN allocated by read-modify-write on snapshots.json — fine single-writer, races if two runs start together. The local lane has no id at all (flat folder, overwritten in place). |
| pins | 3 models | SHA pins (manifest/rproject.toml), branch pins (@main Remotes — and one workflow installs workr @dev), and full vendoring (run-study.yaml) all coexist. The study must name the engine version that produced each snapshot. |
| branches.json | design-only | Multi-branch indirection exists in design.md but no code fetches it; the site reads a flat root. Same for the snapshots.json fetch — the selector component exists but isn't fed. |
| gsm.library | unverified | The central-config precedent (nightly dev/main → release-{name} → prod) is documented but not cloned locally; the upstream demo-study repo (AA-AA-000-0000) likewise. Verify both before requirements assert them. |
Decisions
| ID | Decision | Recommendation |
|---|---|---|
| D-FW1 | Config home: per-study GitHub repo (forkable), vs central config repo, vs in-engine demo. | Per-study repo — your lean, and the hub#131 principle: one study, one repo, one approval trail. The engine keeps only a minimal CI fixture. |
| D-FW2 | Engine dependency: Gilead-BioStats/workr upstream, forked, or vendored? | Upstream dependency, SHA-pinned via manifest/rproject.toml; contribute fixes upstream (the CompileWorkflow lane already exists). Kill the @main/@dev pin inconsistency. |
| D-FW3 | Resolve the engine fork: local-first og_*/fs_* (v0.2 branch) vs GitHub-lane gh_* (dev). | Merge to one engine, two lConfigs — same tree from both lanes (the core finding). Local = dev loop; Actions + gh_* = the D1 acceptance path. |
| D-FW4 | Snapshot publishing: branch-root-as-artifact to Pages (current), vs releases, vs generated site build. | Keep branch-as-artifact — no build step is a feature. Fix id allocation with an Actions concurrency group or run-id suffix. |
| D-FW5 | One artifact-path contract and one phase vocabulary. | output/{phase}/{wf}/{artifact}.csv everywhere (fix gh_SaveData); align 3_reports → 3_reporting or map explicitly in domain config — pick at requirements time. |
| D-FW6 | Status/log truth: derive from file presence vs record from execution. | Record execution (workr's bContinueOnError summary has it all: results/status/failures); keep log.json minimal but real, or drop it from the v1 contract honestly. |
| D-FW7 | Domain registry location and shape (companion to D-APP7). | config/study-config.yaml as sketched — domain = label + chart bundle + workflow phases. Adding a domain is a study-repo PR. |
| D-FW8 | Environment record: what does the provenance chip cite? | manifest.csv (six columns, SHA-filled) + rproject.toml as the per-snapshot environment record; normalize the three producers that currently write slightly different manifests. |
Next steps
① Your pass on D-FW1–8 (one-liners fine, same as the D-APP ledger). ② Verify the two unverified precedents — gsm.library's branch model and the upstream AA-AA-000-0000 demo-study repo — before requirements assert them. ③ Fold this framework plus the design directions into the app-first plan rewrite on #34; the Phase 1–4 requirements then have both a UI headline and a data contract to cite. ④ Coordinate with hub#131 so the CSR and gsm study repos answer the shared questions (layout, pinning, previews, scaffold command) the same way.