_system / plans · companion to cloud-run-migration-sketch
The trial-shape subsystem in this sketch is now code-complete (Cloud Run “Đợt 3”). The B1–B7 care tools and the dashboard were built 2026-05-28; the cloud lift landed 2026-05-29:
care_intake_convert.py: prose drops and school PDFs convert
unattended, with flag-on-failure (bad / orphan / out-of-bounds OCR /
multi-child → a _needs-review.md entry, never a half-baked
write) and the watchword carve-out (a converted note tripping a
watchword still surfaces same-day).care_auth.py: a password hash + a signed session cookie,
stdlib, fail-closed), with a home banner showing the needs-review and open
watchlist counts.profile_store.py
(Local / Drive / Fake) so the tools keep their path-style calls.cloud/care/ (dashboard
Service + a one-image job set + deploy.ps1). 366 tests pass,
lint clean.What remains is operational, not code: the administrator runs the deploy script, and the D-zero pilot (one school, a handful of real children, walked end-to-end) is still the unlock for any real cloud commit. Two tuning calls stay open (the auto-convert confidence threshold; whether to enable the Drive change-push for intake). The flows below are unchanged from the design; this banner records that they are now implemented.
This sketch was drafted 2026-05-28 against an earlier set of assumptions (two Shared Drives, a separate care service account, five user roles, an IAP allowlist synced from Drive membership, a full triage dashboard). The Editor's 2026-05-29 review — the same review that produced cloud-run-migration-v1 for the media line — collapses the Area-1 trial to a much smaller shape. Where this banner and a flow conflict, the banner wins. The legacy text below is kept struck-through for the record of what changed and why, and reverts when the Foundation grows past one operator.
10_Ho-so-thu-huong/ on the single existing Foundation Shared
Drive (phamj.com); the access boundary is folder-level
permission, not a separate Drive. (Overrides Delta 1, §5 Layer 1's
second Drive, the hmt-private-drive-id secret.)‹foundation-service-account›; no
hmt-care-sa, no care@ impersonation subject for
the trial. A separate care SA is the documented exit when Care reaches
production. (Overrides §5 Layer 1.)iap-allowlist-sync-job.)/approve). Mirrors the gate-8 single-administrator relaxation
in qc-enforcement.md/safeguarding.md §3a.
(Overrides Flow A steps b–g, Flow B's review-before-commit, §11's
"no automated profile write.")The media-line cloud-migration sketch (cloud-run-migration-sketch) handles the Area-2 storytelling pipeline. This document handles Area 1 — Beneficiary Care. They share a GCP project, a Workspace, and the same auth pattern, but the workloads, data sensitivity, user roles, and cadence are different enough that one document covering both would obscure both.
The contrasts that matter:
phamj.com). Care is the restricted folder 10_Ho-so-thu-huong/, gated by folder-level permission to the administrator only. The access boundary is the folder permission, not a second Drive. (Team-scale target: a separate private Drive; deferred — see the override banner.)The good news: every cloud primitive used in the media-line plan applies here too. So this sketch concentrates on what is different and treats the shared infrastructure (Artifact Registry, Secret Manager, Cloud Logging, Workload Identity Federation) by reference.
Two simultaneous baselines, per the subplan:
beneficiary_registry_migrate.py, beneficiary_validate.py, beneficiary_promote.py) plus the beneficiary-recorder agent and the /record skill are real. The 01_Ho-so-thu-huong/ directory holds the placeholder layout: Ho-so/ (profiles), So-phuc-loi/ (benefits ledger), Tai-lieu-vao/ (intake), Bao-cao/ (reports), Chi-muc/ (indexes), Danh-sach-an-toan/ (safeguarding watchlist), nguon-tai-tro.yaml (funding-source enum), tu-khoa-canh-bao.yaml (watchword list).Everything that runs today runs on the operator's Windows box, against an in-repo placeholder directory. No real children's data has entered the system yet. Real data has never been on the operator's machine and is not intended to be — the operational store is the restricted 10_Ho-so-thu-huong/ folder on the one Foundation Shared Drive, separate from this repo, gitignored in the placeholder. The data files live on Drive when provisioned; the same tools drive both surfaces via path flags.
That last point matters for the migration. The cloud move is not "lift the laptop into Cloud Run." It is "give the existing tools a real home with the access controls the subplan already specifies." The architectural seams are clean enough to do this without a rewrite.
If you have read the media-line sketch, here is the delta in one screen.
10_Ho-so-thu-huong/ on the one Foundation Shared Drive, gated by folder-level permission to the administrator. The existing service account (hmt-media-ingest) reaches it via workload identity — no second Drive, no second SA, no hmt-private-drive-id secret. (Team-scale target: a separate private Drive + a dedicated hmt-care-sa, restored when Care reaches production / a second person joins.)
so-dang-ky.sqlite) is a projection, not a source. The canonical record is the markdown profile + ledger files in the 10_Ho-so-thu-huong/ folder on the Drive. The SQLite is a denormalised projection rebuilt from those files, kept in sync at write time by the convert/record tools. It lives in GCS via gcsfuse, separately from the markdown source. Loss of the SQLite is operationally annoying but not data loss; rebuild from the markdown.
Tai-lieu-vao/ — and even those are convenience (a Coordinator drop does not have to be picked up within seconds; Monday-morning review is the natural cadence).
hoc-ba, so-diem, chuyen-can, hop-phu-huynh) arrive as PDFs, get fed to Claude Opus as document input, return Pydantic-validated structured fields. Trial: auto-commit on a clean, in-bounds extract to the school_report table + a Block-3 note; out-of-bounds values and multi-child sheets flag a human (Flow B). No equivalent in the media line.
/approve. Two backstops keep auto-write honest: flag-on-failure (bad input flags a human, never a half-baked entry) and the watchword carve-out (a safety signal still surfaces same-day). The Stream-C leakage check (gate 8.5) and the gate-8 dignity sign-off stay manual. (Team-scale target: a per-note human commit; deferred.)
Trial shape (2026-05-29): one Workspace, one Shared Drive. Care is not a second Drive — it is a restricted folder (10_Ho-so-thu-huong/) on the single existing Foundation Shared Drive, gated by folder-level permission. Same project, same region, one service account, additive on top of the media-line components.
The dashboard is the centre of operational gravity for Area 1. Trustees and Principals never touch Claude Code; the dashboard is their entire interface. The Coordinator uses both Drive (for raw drops + reading approved profiles) and the dashboard (for promotion, school-doc review, benefit entry, sponsorship-view generation). The Safeguarding lead lives in the dashboard's /watchlist + child profile pages.
hmt-care-sa
— reuse hmt-media-ingest via workload identity, same as the
media line; (no second Drive) Care is the restricted folder
10_Ho-so-thu-huong/ on the one Foundation Shared Drive, gated by
folder-level permission, so there is no care@ subject and no
hmt-private-drive-id secret; (Layer 3) one simple
user/password login on the dashboard, no IAP allowlist sync, no Apps-Group
role routing, no audience filter (one administrator sees everything). Each
piece below lights up when the Foundation grows past one operator — the
seams are left in place so it is additive, not a rewrite.
‹foundation-service-account› via workload identity (no key download) for both the dashboard service and the care jobs. Grant it Storage Object Admin on the care bucket + Secret Manager Accessor. No hmt-care-sa.10_Ho-so-thu-huong/ folder is permission-restricted to the administrator at the folder level. No care@ subject, no second DWD entry, no second Drive.role_assignments table, no audience filter — one administrator sees everything in the folder they already have permission to.The three-layer model from the media-line sketch is the eventual target. What it adds at team scale is a tighter access boundary on the care folder and a role filter inside the dashboard.
One additional SA: ‹care-service-account› — runs the dashboard + care jobs. Roles: Secret Manager Accessor, Storage Object Admin (scoped to the care bucket only), Logging Writer, Run Invoker on the care jobs. Rationale: blast-radius separation from the media-line SA.
One additional DWD entry, same six scopes from tools/gworkspace_check.py, a dedicated impersonation subject (e.g. care@) added as Content Manager of a separate private Drive; pipeline@ not added to it. This is how the two pipelines isolate at the Workspace layer at team scale.
Cloud IAP gates the dashboard; allowlist synced daily from the private Drive's member list; roles from a role_assignments table (coordinator | safeguarding | principal | trustee | sponsor | editor); the §6.5 audience filter applied per response. The filter is a narrative filter, not access control; the Drive membership is the real boundary, with a penetration check at hardening.
The media-line sketch had one canonical flow (an event from upload to post). Area 1 has six flows. For the trial they collapse onto one operator (the administrator wears every hat), and Flows A and B auto-convert with no per-note human commit — see the override banner at the top. Each walkthrough below carries a trial-shape note; the role tags (Coordinator / Safeguarding / Trustee / Principal) are the team-scale labels, all held by the one administrator for now.
The everyday flow. The administrator visits THCS Tân Khánh, writes up notes about journey-0042's reading progress, then drops the file when back at a laptop.
2026-05-18__truong-1__tham-truong.md (a note, a .docx, or a direct text paste — not necessarily Markdown) into 10_Ho-so-thu-huong/Tai-lieu-vao/2026-W21/journey-0042/ on the one Foundation Shared Drive. Drive UI; phone or laptop.intake-convert-job (and, if wired, a Drive changes.watch nudge) finds the new drop. No "optional push vs Monday review" distinction any more — the schedule run is the pickup.media_sanitise.py on the artefact (EXIF/GPS strip for images; no-op for text). Safeguarding measure 1 still mandatory.beneficiary-recorder agent (Claude Opus, ANTHROPIC_API_KEY from Secret Manager): raw drop in, a structured Block-3 memory entry in the canonical shape out. It infers the petal anchor (default petal 1 for a school visit), writes the entry to Block 3 of the profile via Drive API, and writes the matching profile_entry row. The raw drop is kept beside the entry; the entry's *(file gốc: ...)* line points back to it.beneficiary_validate.py runs on the updated profile. On a clean pass the entry is live in the internal memory — done, no human touched it.10_Ho-so-thu-huong/Tai-lieu-vao/2026-W21/_needs-review.md + a notification) describing what it couldn't process, and leaves the raw drop in place for the administrator to fix or re-drop. Quiet on success; loud only on genuine failure.tu-khoa-canh-bao.yaml: đánh, bỏ học, bạo hành…), the entry is still written to memory but is also surfaced to the administrator (the safeguarding hat) the same day — see Flow D. Auto-write does not silence a safety signal.Why auto-write is allowed here. The internal beneficiary
memory is not an egress; writing a progress note to a private profile sends
nothing outside the workspace. Phase A stays absolute at the
output boundary (a Facebook post, a sponsor email, a website figure
— all still need /approve). This mirrors the gate-8
single-administrator relaxation: the discipline moves to where data actually
leaves, not to every internal keystroke. Reverts to a review-before-commit
gate when a second person joins.
changes.watch push increments a counter./intake; IAP signs her in; dashboard reads the week's drops grouped by journey-id._review.md; picks "promote".promote-job → beneficiary-recorder drafts a Block-3 entry.POST /record/promote/<journey-id> → beneficiary_promote.py writes profile + SQL row + re-validates.school_report row and the Block-3 note unattended. The mandatory
side-by-side human verification is relaxed to flag-on-failure. The
one nuance: a clean-looking-but-wrong OCR (a grade 7.5 read as
75) is the failure mode auto-convert can't fully catch, so the
extractor leans on validation bounds (see below) and the
confirmed_by field records auto when no human
verified — making it queryable later if a number looks off.
The term-2 học bạ for journey-0042 arrives as a PDF.
2026-04-10__hoc-ba-hk2.pdf into Tai-lieu-vao/2026-W15/journey-0042/. The __hoc-ba token routes it to the structured extractor.school-doc-extract-job (scheduled, same pickup as Flow A) pulls the PDF via Drive API, sends it to Claude Opus as a document input with the hoc-ba Pydantic schema, gets back the structured record (gpa_overall, class_rank, subject_grades[], teacher_comment, attendance_pct, conduct_rating).75 fails and flags), attendance_pct 0–100, class_rank ≤ class_size, conduct_rating in the fixed enum. A value outside bounds → the record is held and flagged, never committed.school_report row per child (SQLite, confirmed_by = auto), composes a short Block-3 note keyed to the fields, writes it to the profile via Drive API, links the two via school_report.profile_entry_id._needs-review.md + notifies the administrator, with the PDF and the draft fields attached for a quick fix. The multi-child split stays a human decision because mis-attribution is worse than delay.Tai-lieu-vao/; the note footer and the row's source_pdf column both point to it.The same flow handles so-diem, chuyen-can, hop-phu-huynh with their per-type schemas + bounds. Pydantic models live in tools/school_doc_extract.py.
Drop → click "extract" on the dashboard → side-by-side PDF + editable fields → scan for OCR slips → multi-child tabs reviewed one by one → click "commit" (POST /record/extract), confirmed_by = the Coordinator's name.
care.hmtfoundation.org.vn
→ the trial login URL, the path prefix 01_ →
10_Ho-so-thu-huong/, and the operator is the administrator.
A scholarship cheque is handed over. The ledger records it the same day, because Area-4 transparency depends on the ledger being complete in real time.
/child/journey-0042), click "record benefit".tet-2026-quy-cong-dong; dropdown validated against nguon-tai-tro.yaml), delivered by, handover signed by (Mẹ — Nguyễn Thị Hoa). Optionally upload the signed receipt PDF.POST /benefit/record. Server-side: writes a markdown ledger entry into 10_Ho-so-thu-huong/So-phuc-loi/journey-0042/2026-04-12__hoc-bong-hoc-ky-2.md (Drive API), uploads the receipt into the same folder, writes a benefit row to SQLite with benefit_id = ben-2026-0042-003 (auto-sequenced). benefit_record.py does the validation and the writes (B3 surface).watchlist_item rows and the action buttons return when
a second person takes the safeguarding hat.
The 1st of the month, after the schedule runs:
watchlist-monthly-job reads the registry + profiles, applies the §7 signal rules, writes 10_Ho-so-thu-huong/Danh-sach-an-toan/<YYYY-MM>__watchlist.md.watchlist-keyword-job sends an alert only on a non-empty day — the genuinely-can't-wait signals. Most days: no notification at all (silence = clear).The job is fully automated; the administrator's judgement is the action. Nothing auto-resolves a signal — an alert stays until the administrator has dealt with it, the same way the gate-8 dignity check is never auto-set.
A separate Safeguarding lead opens /watchlist/<YYYY-MM>; each signal is a card with four buttons (Dismiss / Request Coordinator follow-up / Open escalation / Re-surface next month); each action writes a watchlist_item row. Restored when the team grows past one operator.
End of quarter. The administrator opens the dashboard.
child, benefit, profile_entry, school_report for the cohort), calls the report-writer agent (Claude Opus) for the narrative, then renders three artefacts from one run: a reader-friendly HTML page (the primary, brand-styled, charts inline), plus markdown and PDF as the audit/archive copies, plus HXL-CSV for the financial cuts.10_Ho-so-thu-huong/Bao-cao/2026/2026-06-30__cohort__cung-em-tien-buoc-2026.{html,md,pdf}. The dashboard opens the HTML directly. ~10s SQL + ~15–30s Claude narrative.Reports are deterministic-given-state — same data, same SQL output; only the Claude narrative drifts slightly between runs. The rendered HTML+md+PDF are written to the Drive at generation time for audit.
End of quarter. Ông Nam sponsors journey-0042. The family has given named consent for first-name + photo sharing with this one sponsor (consent_observed.sponsor_named_share: given). The Coordinator generates the quarterly view.
funding_source = "2026-ong-nam", applies the sponsor audience filter (no sensitive context, names per consent_observed), produces a quarterly markdown narrative.01_Ho-so-thu-huong/Goc-nhin-nha-tai-tro/ong-nam/2026-Q2__journey-0042.md. The dashboard returns the file; the Coordinator reviews it, then personally sends it to ông Nam via a separate email step (Area-3 build).Where the family declined named disclosure, the same renderer produces a journey-NNNN-addressed view (no name, no photo). Same depth of progress material; only the identifier differs.
| Subplan tool / surface | Cloud Run home | Notes |
|---|---|---|
tools/beneficiary_registry_migrate.py (B1, exists) | one-shot job; run during C2 bootstrap | Schema lives in GCS-mounted SQLite. Idempotent. |
tools/beneficiary_validate.py (B1, exists) | called in-process by promote-job and the dashboard | Unchanged. |
tools/beneficiary_promote.py (B2-scaffold, exists) | intake-convert-job (was promote-job) | Trial: scheduled + auto. Reads new drops, runs beneficiary-recorder (real AnthropicPromoteEngine, key in Secret Manager), auto-writes the Block-3 entry + profile_entry row. Flags to _needs-review.md on low confidence instead of writing. The "human clicks promote" path is the legacy form. |
tools/school_doc_extract.py (B2-full, not yet) | school-doc-extract-job | Trial: auto-commit on a clean, in-bounds extract (confirmed_by = auto); flags multi-child sheets + out-of-bounds values for a human. Claude Opus document input; Pydantic + range bounds per doc type. |
tools/benefit_record.py (B3, not yet) | called in-process by the dashboard | Validates funding_source, category, petal. Writes Drive markdown + SQL row. |
tools/benefit_report.py (B3) | area4-emit-job | Monthly. Emits markdown + HXL-CSV (later IATI XML). |
tools/beneficiary_index_rebuild.py (B4) | index-rebuild-job | Nightly. Three indexes + cohort dashboard. |
tools/beneficiary_consistency_check.py (B4) | consistency-check-job | Nightly. Drive-vs-SQL drift report; alerts on findings. |
tools/report_child.py (B5) | report-child-job | On-demand from dashboard. |
tools/report_cohort.py (B5) | report-cohort-job | On-demand. |
tools/report_school.py (B5) | report-school-job | On-demand. |
tools/safeguarding_watchlist.py (B6) | watchlist-monthly-job + watchlist-keyword-job | Cron. Writes Drive markdown + SQL rows; pings Safeguarding via Chat. |
tools/sponsorship_view_render.py (Area-3) | sponsorship-render-job | Deferred until Area-3 starts. |
app/ — the FastAPI dashboard (B7) | hmt-care-dashboard service | Server-rendered HTMX. Trial: one simple user/password login (not IAP + Drive-membership allowlist). One administrator view; no per-role audience filter. |
NEW — tools/profile_store.py | library; in every care job | Hides Drive API behind a path-shaped interface. The seam that lets existing tools keep their filesystem-style calls. |
NEW — tools/iap_allowlist_sync.py | iap-allowlist-sync-job | Trial: deferred. One simple login replaces the IAP allowlist sync. Returns with a second user / role layer. |
NEW — tools/drive_membership_audit.py | drive-membership-audit-job | Trial: deferred (one operator, one folder). Returns when the Drive folder is shared beyond the administrator. |
NEW — tools/care_cache_refresh.py | care-cache-refresh-job | Nightly. Pulls profile markdown from Drive into a GCS read-through cache so dashboard reads are fast. |
tools/journey_leakage_check.py (subplan §10) | called from media-line qc-job | The Stream-C gate 8.5. Lives in the media-line image; takes a profile path in the Care folder as input. |
| Surface | Lives in | Why |
|---|---|---|
Profile markdown (journey-NNNN.md) | Care folder on the Foundation Shared Drive (canonical) | Humans browse and (occasionally) edit directly via Drive UI. Append-only is enforced by tool discipline + Drive revision history. |
| Benefits ledger (markdown + receipts) | Care folder on the Foundation Shared Drive (canonical) | Same reason. Receipt PDFs/photos live in the per-child ledger folder. |
Intake drops (Tai-lieu-vao/) | Care folder on the Foundation Shared Drive | Where the Coordinator drops raw materials. EXIF/GPS strip is enforced at promotion time by media_sanitise.py --verify. |
Reports (Bao-cao/<YYYY>/) | Care folder on the Foundation Shared Drive | Generated by Cloud Run jobs, written back via Drive API. The Drive copy is the audit record. |
Watchlist (Danh-sach-an-toan/) | Care folder on the Foundation Shared Drive | Cron job writes there; Safeguarding lead reads from there or via dashboard. |
Sponsorship views (Goc-nhin-nha-tai-tro/) | Care folder on the Foundation Shared Drive | Coordinator copies into email outside the workspace. |
Indexes (Chi-muc/) | Care folder on the Foundation Shared Drive | Regenerable; the Drive copy lets a Coordinator open the index offline. |
SQLite (so-dang-ky.sqlite) | GCS ‹gcp-project›-care-db, mounted via gcsfuse | Projection of the markdown. Performance need: dashboard queries cannot wait for hundreds of Drive API calls. Loss is annoying, not data loss. |
| Profile/ledger read-through cache | GCS ‹gcp-project›-care-db (separate prefix) | Nightly mirror of profile markdown so dashboard reads are sub-second. Stale-tolerant; the dashboard offers a "refresh" action that re-reads from Drive on demand. |
| School-doc PDFs (post-extraction) | Care folder (unchanged) + GCS render cache | The Drive copy is the audit artefact; GCS holds a flattened-for-rendering version while the Coordinator is reviewing. |
| Secrets | Secret Manager (shared with media line) | No new drive-id secret — the one Shared Drive id is reused. |
| Logs | Cloud Logging | Care jobs tagged area=care for filtering; Safeguarding-touching reads (any access to a child profile by a non-Coordinator role) get a dedicated audit log sink. |
care-cache-refresh-job — nightly 04:00 ICT. Pulls profile markdown from the Care folder into GCS for fast dashboard reads.intake-convert-job / school-doc-extract-job — scheduled (e.g. hourly). Auto-convert new drops (Flows A & B); flag-on-failure.index-rebuild-job — nightly 02:00 ICT. Rebuilds the three indexes (by-cohort, by-school, by-child/_search.md) in the Care folder.consistency-check-job — nightly 02:30 ICT. Drive-vs-SQL drift; alerts on findings.watchlist-keyword-job — daily 07:00 ICT. Narrow keyword pass; alerts the administrator if anything fires.watchlist-monthly-job — 1st of month, 06:00 ICT. Full signal sweep → alert (Flow D).area4-emit-job — 28th of month, 06:00 ICT. Monthly Area-4 financial transparency emission (HXL-CSV; later IATI XML quarterly).iap-allowlist-sync-job (one login instead) and drive-membership-audit-job (one operator, one folder).changes.watch on Tai-lieu-vao/ → a webhook on the dashboard service → runs the convert job sooner than the schedule. Convenience only; the scheduled run works without it.channel-publisher has none in Phase A.roles/run.invoker on the commit endpoints. The trial replaces this with the output-boundary line above plus the flag-on-failure + watchword backstops.The dashboard is the most substantial new build. The subplan (§B7) gave it 5–8 days; that estimate is pre-cloud and assumes a localhost FastAPI. On Cloud Run behind one simple login, plan ~10 days for the first cut, ~3 more for polish after the pilot.
/audit/* pages are deferred (one viewer).
| Path | Role(s) | What it shows |
|---|---|---|
GET / | any | Home: cohort summary, today's intake count, watchlist count for safeguarding role only. |
GET /search?q=<name> | any | Real-name lookup; reads by-child/_search.md from the read-through cache. Returns journey-id + school. |
GET /child/<journey-id> | any, audience-filtered | Full profile: Block 1 (audience-filtered), Block 2 programme history, Block 3 progress notes, benefits ledger, school-report grade-trend mini-chart, sibling cluster, followups outstanding. |
GET /school/<slug> | coord, sg, principal, trustee, editor | School page: every child in this school, grouped by programme, with status + last-update + 8-petal coverage miniature. |
GET /cohort/<programme>/<year> | coord, sg, trustee, editor | Cohort page: count, school spread, gender mix, age range, status distribution. |
GET /watchlist | safeguarding only | Current month's triage queue. Action buttons per item. |
GET /watchlist/<YYYY-MM> | safeguarding only | Historical watchlist snapshots. |
GET /reports | coord, sg, principal, trustee, editor | Three generators: per-child, per-cohort, per-school. Parameter pickers. |
GET /intake | coordinator only | Current week's raw drops grouped by journey-id. Review and promote buttons. |
POST /record/promote/<journey-id> | coordinator only | Commits a draft profile entry. Launches promote-job if not already drafted. |
POST /record/extract/<file> | coordinator only | Launches school-doc-extract-job; returns extracted fields for side-by-side review. |
POST /benefit/record | coordinator only | Writes the ledger entry + SQL row. |
POST /watchlist/<item-id>/action | safeguarding only | Triage action (dismiss / followup / escalate / re-surface). |
POST /sponsorship/<sponsor-id>/render | coordinator only | Launches sponsorship-render-job. |
POST /report/<kind> | any (audience-filtered output) | Launches the relevant report job; returns when complete. |
GET /audit/me | any | The viewer's own audit trail (what they have accessed in the last 90 days). Trust-by-transparency. |
GET /audit/role/<role> | safeguarding only | Cross-user audit; what each Coordinator / Principal / Trustee has accessed. |
Every read of a child profile by a non-Coordinator role writes one line to the audit log:
{ "ts": "2026-06-15T08:32:14+07:00",
"viewer": "bac.binh@hmtfoundation.org.vn",
"role": "trustee",
"path": "/child/journey-0042",
"fields_visible": "trustee_filter",
"ip": "..." }
The Safeguarding lead can see this audit at /audit/role/trustee. Trust-by-transparency — the people whose data is in the system can be shown that access is recorded.
Inherit the media-line invariants (no channel-publisher cron, gate-8 manual, etc.). On top of those:
school_report
rows unattended — that is the point of the relaxation. This is consistent
with Phase A because the internal beneficiary memory is not an egress:
nothing about a child leaves the workspace by a note being auto-written to a
private profile. The bright line is unchanged at the place data actually
leaves — a Facebook post, a sponsor email, a website figure — all
still gated by /approve. The legacy "no automated profile write"
invariant below is the team-scale form, restored when a second person joins.
/approve. Outputs leave the subsystem only via Area 2 (Stream C with the gate-8.5 leakage check) or Area 4 (aggregated finance, programme-not-child level). Both keep their own approval gates. This is the invariant that does not relax./record; cron reads, humans write; Scheduler SA holds no roles/run.invoker on commit endpoints.Assumes the media-line cloud migration is done first (it provides the project, the Artifact Registry, Secret Manager, the base image, IAM patterns). If Area 1 goes first, fold the media-line C0–C2 into the front of this plan.
10_Ho-so-thu-huong/ on the existing Foundation Shared Drive; set folder-level permission to the administrator only. (No second Drive for the trial.)hmt-media-ingest reaches the folder; grant it Storage Object Admin on the new care bucket + Secret Manager Accessor. (No care@ subject, no hmt-care-sa.)‹gcp-project›-care-db.tools/profile_store.py — the Drive-API-shaped layer that lets existing tools keep their path-style calls (profile_store.read("Ho-so/journey-0042.md") ↔ Drive API).beneficiary_promote.py and beneficiary_validate.py behind it.FakeProfileStore — same pattern as FakeDriveClient in ingest_watch.py.hmt-media-ingest (no new SA, no new DWD entry — the existing impersonation already reaches the Drive).beneficiary_registry_migrate.py as a one-shot Cloud Run job — creates the five tables on the GCS-mounted SQLite.gworkspace_check.py — must pass against the existing SA.hmt-care-sa + a care DWD entry + add care@ to a separate private Drive. Deferred.)tools/school_doc_extract.py with Pydantic schemas for the four doc types.school-doc-extract-job.hoc-ba PDF round-trips to structured fields + a draft Block-3 note.tools/safeguarding_watchlist.py (B6).tools/beneficiary_index_rebuild.py + tools/beneficiary_consistency_check.py (B4).care-cache-refresh-job for the read-through cache.tools/report_child.py, tools/report_cohort.py, tools/report_school.py (B5).report-writer agent prompt.tools/benefit_report.py + area4-emit-job (B3, the Area-4 monthly).app/: FastAPI, Jinja templates, HTMX, Pydantic schemas, the read views (child, school, cohort, watchlist, intake, reports, audit).hmt-care-dashboard Cloud Run service.iap-allowlist-sync-job + drive-membership-audit-job./audit pages.care.hmtfoundation.org.vn; Cloud Run custom domain mapping.Per subplan §B-zero: one school, one Coordinator, five real children, hand-rolled profiles + five weeks of intake, walked end-to-end on the pre-cloud setup first. Do this before D3 lands. Findings feed the dashboard's first cut. The pilot proves the operational discipline, not the cloud infrastructure.
Total: ~27 working days of build, plus the pilot operational time. Most of it is the dashboard (D6) and the watchlist/reports/extraction trio (D3–D5). Pure cloud plumbing is ~5 days (D0–D2 + D7).
Steady state, six partner schools, ~150 children active across the cohort, ~30 benefit rows/month, ~15 progress notes/week, monthly watchlist + daily keyword pass:
| Line | Estimate / month | Note |
|---|---|---|
Cloud Run service hmt-care-dashboard | ~$10–15 | Same shape as media webhook. Single administrator, so min-instances can be 0 for the trial (cold start is acceptable for one user) or 1 for snappiness. |
| Cloud Run jobs (care side) | ~$3 | Nightly + daily + monthly + auto-convert. Sub-cent per invocation. |
| GCS storage (care) | ~$1 | SQLite + cache + render staging; small. |
| Login (trial) | $0 | One simple user/password. (Team-scale Cloud IAP is also $0 for Workspace accounts.) |
| Cloud Logging | <$1 | Standard job logs. (Per-role audit sink deferred — one viewer.) |
| Cloud Scheduler | <$1 | ~10 entries. |
| Anthropic API (care-side) | $15–40 | Promote (small prompt) + school-doc extract (PDF document input) + report-writer narratives + watchlist keyword tag. Dominated by school-doc extract during heavy school months (May/Dec for hoc-ba). |
| Care-side total | ~$35–60 |
On top of the media-line ~$20–25/month + ~$10–30 Anthropic, the all-Foundation monthly is ~$65–115. Order of magnitude smaller than any SaaS case-management tool, and the Foundation owns the code and the data.
drive-membership-audit-job + iap-allowlist-sync-job against a separate private Drive's member list; deferred.)consistency-check-job nightly; the dashboard's profile page also runs a lightweight check on-load and shows a warning banner if drift is detected.confirmed_by column in school_report is non-null by schema.journey_leakage_check.py (subplan §10) is a hard QC gate. The check runs against the canonical profile on Drive; the media-line job pulls it via Drive API.school_doc_extract.py would also work if needed.Resolved by the 2026-05-29 Editor review (see the override banner): one Workspace + one Shared Drive (Care = restricted folder, no second Drive); one SA (hmt-media-ingest, no care@, no hmt-care-sa); one administrator (no five-role model, no role_assignments table, no audience filter); one simple user/password login (no IAP allowlist sync); dashboard on the default Cloud Run URL for the trial; Flows A & B auto-convert; Flow D is an alert; Flow E renders HTML; Flow F parked. The questions below are the ones still genuinely open.
Read the media-line glossary first. New here:
10_Ho-so-thu-huong/)tools/profile_store.py) that hides Drive API calls behind a path-shaped interface. Lets the existing tools keep their filesystem-style reads / writes without rewriting to a Google API client.tools/journey_leakage_check.py), specific to Stream-C pieces sourced from a real profile. Asserts no Block-1 identifying field survives into the published draft.