Internal — roadmap. Design document, not a public release. Draft, subject to change. noindex.

_system / plans

Cloud Run + one Google Workspace — migration plan v1

Media & Beneficiary Care · drafted 2026-05-29 · supersedes the v0 Sheets↔Cloud-Run interface note.

Build status: Wave 1 + Wave 2 DONE code-complete & committed, awaiting deploy · Wave 3 (Care) WIP parallel session. See §10 + the deploy checklist.

Phase A still absolute. Cloud Run handles the pipeline and the review surface only. No branch ever auto-posts to Facebook. channel-publisher has no Cloud Run endpoint and no IAM permission to call the Graph API during this stage.

Decisions locked (2026-05-29)

  1. One Google Workspace for all Foundation operations. Media and Beneficiary Care live in the same Workspace / Shared Drive but under different root folders; Care gets tighter access (§2).
  2. A single user role for the trial: administrator (= coordinator = editor = safeguarding). More roles later if needed (§4).
  3. Minimise human involvement: the pipeline runs as far as it can on its own and stops only at one human approval gate (Phase A — §4, §6).
  4. Gate-8 safeguarding is folded into the administrator's single approval (one click + a required dignity checkbox). Rule files edited 2026-05-29 (§7g).
  5. The in-repo placeholder 01_Ho-so-thu-huong/ has been deleted (done).
  6. phamj.com is a paid Workspace domain (confirmed, billing enabled) → an existing Shared Drive + one running service account. 0 new service accounts needed for the trial (§3). Cloud Run creates the event folder via an instant webhook (§6, §7a).

1. One Workspace, merged folder tree

Everything on the Foundation's existing Shared Drive (HMT_SHARED_DRIVE_ID=<SHARED-DRIVE-ID>, on Workspace domain phamj.com; GCP project <GCP-PROJECT>). The two business domains are separated by root folder + member permission, not by a separate Drive.

HMT Foundation Shared Drive
├── 01_Tai-lieu-thuc-dia/      MEDIA: field uploads (pipeline reads here)
│   └── <YYYY-MM-DD>__<event-slug>/
│       ├── brief.yaml         the filled form, MOVED here (not copied)
│       ├── photos/            coordinator uploads raw photos
│       └── videos/            (later stage)
├── 02_Bai-da-duyet/          MEDIA: approved-posts archive
├── 03_Tai-san-thuong-hieu/   MEDIA: brand assets
├── 04_Chuong-trinh/          MEDIA: by programme
├── 05_Lich-va-ke-hoach/
│   └── Phieu-va-bang-dieu-khien/  MEDIA: form responses Sheet + Dashboard
├── 06_Tuan-thu-va-dong-thuan/  restricted (consent; Foundation-managed)
├── 07_Quan-tri/
├── 99_Thu-muc-tam/    (former 08_ retired — see §7a)
└── 10_Ho-so-thu-huong/       BENEFICIARY CARE (tighter access — §2)
    ├── nguon-tai-tro.yaml · tu-khoa-canh-bao.yaml
    ├── Tai-lieu-vao/ · Ho-so/ · So-phuc-loi/ · Chi-muc/
    ├── Danh-sach-an-toan/ · Bao-cao/
    └── so-dang-ky.sqlite

Why 10_ not 01_: 01_ is already Media's field-uploads folder; 10_ avoids the clash and sorts after the Media block. This changes subplan §1 (which had Care on a separate Drive). The one-Workspace decision replaces that; the safeguarding boundary moves from "separate Drive" to "separate folder + separate permission" (§2). Folder names stay ASCII kebab-case per naming-convention.md; the --root flag on every Care tool is passed explicitly, so renaming the folder breaks nothing at runtime.

2. The safeguarding boundary inside one Workspace

Care is children's data, so it stays tighter than Media even in the same Workspace.

3. How many service accounts

For the trial: no new SA needed. One is enough — and it already exists.

Because phamj.com is a paid Workspace there is already a Shared Drive and a running service account:

Reuse this SA for all of Cloud Run. Grant it three more roles: Storage Object Admin on the bucket(s), Secret Manager Accessor, Cloud Run invoker (between jobs). No SA #2/#3.

Why one is enough (your question, "do we absolutely need multiple SA?"):

When to split SAs later (noted, not now):

WhenExtra SAWhy
Beneficiary Care goes to productionhmt-care-runner Children's data; split so one leaked key can't reach the profiles. This is the one time the split earns its keep — same moment we set up Care permissions (§2).
Phase B (FB API posting)hmt-publisher IAM enforces autonomy-phase.md: only this SA can call the Graph API; the normal runner can never post.

Bottom line for setup: 0 new SAs now. Just (a) confirm <SA-NAME>'s key is still valid, (b) grant it the three GCP roles above. DWD (impersonating hp@phamj.com) is not set up and the Drive/Sheets pipeline doesn't need it.

4. User role: one administrator

One role for the trial: administrator = coordinator = editor = safeguarding.

5. Can Claude deploy Cloud Run? Ongoing updates

5a. Can Claude deploy it directly?

No, not from this machine — checked: gcloud and docker are both absent, and no Cloud Run tooling is wired into the session. Even installed, you should run the deploy: it needs the project-owner credential (the Foundation's, per workspace-ethics.md §2), and creating cloud resources is a hard-to-reverse outward action.

What Claude does do: writes every file so you run one command — Dockerfile(s), cloudbuild.yaml / gcloud run deploy --source, the Apps Script .gs, a commented deploy.ps1, and the RUNBOOK.md deploy/incident section.

5b. Updating Cloud Run as we keep working in VS Code + Claude

Edit tools/ in VS Code (with Claude)
   → git commit            (repo stays the source of truth)
   → run deploy.ps1        (or: gcloud run deploy <svc> --source .)
        Cloud Build builds the image in the cloud, pushes it,
        Cloud Run cuts a new revision and shifts traffic to it
   → new revision live; old kept for one-command rollback

6. What "Drive push" means + what triggers the pipeline

Three triggers at three moments. Cloud Run creates the folder (per your instruction); Apps Script just rings the bell:

  1. Form submit → Cloud Run creates the subfolder (instant webhook).
  2. Media uploaded → run the pipeline. This is "Drive push". The Cloud Run Job ingest-watch (Cloud Scheduler every 2–5 min) scans; when it sees photos in photos/ + an existing brief.yaml it starts intake→curate→draft→QC. SHA-256 dedup means re-scans don't re-run a processed bundle.
  3. (optional, later) An "Upload done" button on the Dashboard → /ingest-now?slug=… runs the pipeline immediately, skipping the poll wait. Not needed for the trial.

Direct answer: the pipeline (step 2) starts after media is uploaded (poller detects it), not at form submit. Form submit only makes Cloud Run create the folder + drop the form into it (step 1). A bundle is "ripe" to run only when it has both brief.yaml and photos in photos/.

7. Media end-to-end (a–k)

7a. File uploads — form-first (Cloud Run creates the folder)

Exactly your two points: (1) the form is filled and saved to Drive first, then Cloud Run creates the correctly-named subfolder; (2) the completed form is then moved into that subfolder.

(i)   Coordinator fills + submits the Form
         → one row in the responses Sheet (05_Lich-va-ke-hoach/Phieu-va-bang-dieu-khien/)
           (master log; a response is a Sheet ROW, not a loose file)
(ii)  Apps Script onFormSubmit ──instant webhook (~sec)──▶ Cloud Run Service /event-folder
(iii) CLOUD RUN creates 01_Tai-lieu-thuc-dia/<YYYY-MM-DD>__<event-slug>/
         ├── brief.yaml       ← filled form written here  (#2: "moved into the event folder")
         ├── photos/
         └── videos/
      + writes one Dashboard row (state = cho-upload)        [~3–5s total]
(iv)  Coordinator opens the new subfolder, uploads raw photos to photos/
(v)   Dashboard shows: folder link, checklist, job status

Folder name is generated by Cloud Run from event_date + school + activity_type, ASCII kebab-case, ≤60 chars. e.g. 2026-06-01 / THCS Tam Thanh / skills session → 2026-06-01__sinh-hoat-ky-nang-tam-thanh. The naming logic lives in the Cloud Run code (testable, same repo), not in Apps Script.

The filled form "lands" in the right event folder. Its contents are written as brief.yaml inside the event subfolder — that is how "move the completed form into the relevant trip/event folder" is implemented. Because the original response is a Sheet row (not a file), there is no need for a separate 08_ folder: the responses Sheet (master log) lives in 05_ alongside the Dashboard, and the per-event copy lives in the event folder.

Why 08_ was dropped (your question, 2026-05-29): 08_Phieu-thong-tin-bai-dang/ used to be the "place the form lands." But the filled form now becomes brief.yaml in the event folder, and the original is just a row in the responses Sheet. The only thing that needs a home is that Sheet, which moved to 05_Lich-va-ke-hoach/Phieu-va-bang-dieu-khien/. 08_ has been retired from the canonical Drive tree (naming-convention.md, build_drive_tree.py).

7b. Drive push

See §6. The pipeline runs when media upload finishes (poller detects photos + an existing brief), not at form submit.

7c. Intake job + Dashboard sync

7d. Curate job

7e. Draft job

Yes — it follows every rule + voice signature. caption_draft.py uses Opus 4.7 vision with the brand-voice system prompt (Knowledge/Brand/system-prompt.md) + the prompt-cached few-shot corpus (few-shot.yaml). Rules enforced in this path: brand-voice.md (hook→why→outcome→close, no em-dash, no AI tells, 80–140 VN words, emoji discipline), content-ops.md (story shape + activity→petal), terminology/lexicon (canonical programme names). The output then goes through QC (7f), which re-checks these rules — the draft complies, QC verifies. To-do: seed few-shot.yaml with 3–5 real approved captions before the first live run.

7f. QC job

Gates 1–7 run automatically in the pipeline (bundle_validate + brand-voice-editor + claim-checker), writing gate_findings to STATUS.md → onto the Dashboard. Gate 8 folds into the approval step (7g).

7g. Safeguarding sign-off — folded into approval (locked)

7h. Editor approval — edit in place + update the workspace

7i. Pack job — accounts for edits at approval

publish_pack.py runs after the edited caption is written back (7h), so the pack always uses the approved copy, not the old draft. Conditions unchanged: state=approved + clean gate-1 + DRAFT marker stripped. It emits Sandbox/_review-prints/<slug>/ (final media + post sheet + alt-text to paste into the Facebook accessibility box). The pack link shows in the Dashboard preview column.

7j. Manual posting — posted_id on the dashboard

Yes. The admin posts to Facebook by hand, then enters posted_id in the Dashboard column. Apps Script writes it back to publish.yaml + STATUS.md (state→published). posted_id is no longer buried in a YAML file someone has to open; it's a cell on the Dashboard.

7k. Indexing

index_registry.py records the asset→rendition→caption→post→(later) performance lineage in registry.sqlite under hmt-bundles/index/. When posted_id is entered (7j) a post row is written. The lineage log is the system of record; the Dashboard is the human surface. Performance rows (reach/likes) come with the later API stage.

8. What moves to Cloud Run, what doesn't

ComponentFormTriggerNote
Ring the bell on form submitApps Script (bound to Form)onFormSubmitonly calls the webhook, does NOT create the folder
event-folder (create subfolder + move form)Cloud Run Servicewebhook from Apps Script (~sec)naming logic lives here (§6, §7a)
ingest-watchCloud Run JobCloud Scheduler 2–5 minscans Drive, builds the bundle
intake→curate→draft→QCCloud Run Job(s)chained after ingestcurate=CPU; draft=Opus 4.7
Push state to Dashboardinside each job (last step)on job completionevent-driven, ~sec (§7c)
status-reconcile (backup)Cloud Run JobCloud Scheduler 15–30 minonly re-syncs failed pushes
decisions (write back approve/edit)Cloud Run ServiceApps Script webhook onEditverify id-token + nonce
media_retouch / media_triageruns inside a job (CPU)within the jobfree/CPU, no separate endpoint
video_editdeferredno video yet (7d)
channel-publisherno endpointPhase A: no Graph-API IAM

Note: all Media jobs/services share one SA (<SA-NAME>, §3) via workload identity — no key downloaded. media_retouch/media_triage/video_edit run inside the pipeline job containers (same image), not as separate services.

Storage: bucket gs://hmt-bundles/ (Media) for the trial. The hmt-care bucket is only created when Care goes to production. The laptop two-way-syncs with gcloud storage rsync, run manually during the trial.

9. Cost estimate (2–3 posts/week, trial)

ServiceCost
Cloud Run (Services + Jobs)~0 (under free tier)
Cloud Scheduler (a few jobs)0 (3 free)
Cloud Storage (hmt-bundles < 5 GB)~0
Secret Manager (5–8 secrets)0
Anthropic Opus 4.7 vision~$5–15/mo (same wherever it runs)

Total GCP ~ 0–3 USD/month, mostly under the free tier.

10. Roadmap + status (updated 2026-05-29)

DONE code complete WIP in progress DEPLOY awaiting administrator LATER not started

  1. DEPLOY No new SA. Grant <SA-NAME> three more roles (Storage Object Admin on hmt-bundles, Secret Manager Accessor, Cloud Run invoker) and confirm its key is valid. deploy.ps1 §1 does the grants automatically; you just gcloud auth login + confirm the key. (~15 min, §3.)
  2. DONE Wave 1 (Media core): the Apps Script "bell" + event-folder Service (§7a) + ingest-watch Job + bucket + deploy.ps1. Committed eb94e35: event_naming.py, event_folder_core.py, cloud/event_folder/, cloud/ingest_watch/, onFormSubmit.gs. Awaiting deploy.
  3. DONE Wave 2 (Dashboard + approval): Sheet Dashboard + in-job state push + status-reconcile backup + decisions Service + write-back Apps Script (§7c, §7g–j). Committed 7618574: dashboard_sync.py, decisions_core.py, cloud/decisions/, cloud/status_reconcile/, onEdit.gs, shared cloud/_oidc.py. Gate-8 folded into duyet + required dignity_ok (never auto-ticked). Awaiting deploy.
    Minor deviation from §8 ("id-token + nonce"): used state-idempotency instead — a Sheet edit has no natural nonce, and re-applying the same decision is a no-op, giving the same retry-safety with less coupling.
  4. WIP Wave 3 (Care): 10_Ho-so-thu-huong/ on the Drive + the Care dashboard + auto-convert care jobs. In progress in a parallel session. Trial shape: one Workspace, one 10_ folder, one SA <SA-NAME> (no second SA at trial — differs from the earlier §3 sketch), one simple login.
  5. LATER Later: caption-draft as its own Job (when >5 posts/week); video; CI auto-deploy; Phase B (API posting) with the hmt-publisher SA.

10a. Cross-session integration fix (2026-05-29)

The Care session caught a schema bug in the Wave-1 code: event_folder_core wrote a Dashboard row with the wrong columns (folder_id/coordinator/…, range A:G), which did not match the canonical 10-column §7a schema that the Wave-2 dashboard_sync upserts against. Fixed: event_folder_core now imports dashboard_sync.DASHBOARD_COLUMNS and writes folder_link + range A:J, so the event-folder row and the Wave-2 push share one row keyed by event_slug. Combined: 366 tests pass, lint clean. The fix lands with the Care session's commit.

11. Locked + remaining

Locked (2026-05-29):

Remaining — OPERATIONAL (Wave 1 + 2 are no longer code work):

  1. DEPLOY Create the Dashboard Sheet in 05_…/Phieu-va-bang-dieu-khien/ with the 10-column header in §7a order (event_slug · folder_link · state · checklist · gate_findings · preview · caption_fb · decision · dignity_ok · posted_id). decision = dropdown, dignity_ok = checkbox. Copy its ID.
  2. DEPLOY Fill .env (the new Wave 1+2 vars): HMT_DASHBOARD_SHEET_ID, HMT_ALLOWED_CALLERS (the webhook caller email, usually the Form owner), HMT_SHARED_DRIVE_ID (already set). Leave HMT_DASHBOARD_TAB / HMT_OIDC_AUDIENCE blank.
  3. DEPLOY gcloud auth login (owner of the GCP project) + confirm the SA key is valid (py tools/ingest_watch.py --check-connection).
  4. DEPLOY Run pwsh cloud/deploy.ps1 (enables APIs, grants roles, bucket, 2 Services + 2 Jobs, schedulers).
  5. DEPLOY Wire the two Apps Scripts: paste SERVICE_URL into the Form script (trigger onFormSubmit); paste DECISIONS_URL into the Dashboard script (installable trigger onDashboardEdit — a simple onEdit will not work).
  6. DEPLOY Smoke test per RUNBOOK: submit a form → folder + row appear; drop a photo → bundle builds; approve with dignity_ok → packed; type posted_id → published.
  7. WIP Care (Wave 3): awaiting the parallel session's commit, then run cloud/care/deploy.ps1.