_system / plans
Phase A still absolute. Cloud Run handles the pipeline and the review surface only. No branch ever auto-posts to Facebook.
channel-publisherhas no Cloud Run endpoint and no IAM permission to call the Graph API during this stage.
01_Ho-so-thu-huong/ has been deleted (done).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).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.
Care is children's data, so it stays tighter than Media even in the same Workspace.
10_Ho-so-thu-huong/
and 06_Tuan-thu-va-dong-thuan/ get item-level access restricted to
the administrator (+ safeguarding lead when a second role exists). Media-only
members can't see them. Workspace Shared Drives support this.hmt-care-runner (§3) and
only then grant it 10_Ho-so-thu-huong/; the trial Media SA
never touches Care because the Care folder isn't shared with it..gitignore enforces
it; the placeholder folder was deleted. Data lives on the Drive, code in git.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:
<SA-EMAIL> (the existing Media ingest service account;
client_id + key held privately in .env / service-account.json,
not published here). Drive/Sheets/Docs all work SA-direct on
this Shared Drive.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?"):
hmt-appsscript SA.When to split SAs later (noted, not now):
| When | Extra SA | Why |
|---|---|---|
| Beneficiary Care goes to production | hmt-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.
One role for the trial: administrator = coordinator = editor = safeguarding.
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.
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
gcloud run deploy --source . is the simplest path — no local
Docker; Cloud Build builds in the cloud. Each run = a new revision, auto traffic
shift, rollback with update-traffic --to-revisions <rev>=100.main auto-deploys on
push. Not for the trial — manual deploy is clearer while experimenting.deploy.ps1. Claude never deploys; Claude prepares files + commands.Three triggers at three moments. Cloud Run creates the folder (per your instruction); Apps Script just rings the bell:
05_Lich-va-ke-hoach/Phieu-va-bang-dieu-khien/, the master log of every
submission). A response is a Sheet row, not a loose file in a folder.onFormSubmit does not create the folder; it
just calls POST /event-folder with the response id + a Google id-token.<YYYY-MM-DD>__<event-slug> name, creates
01_Tai-lieu-thuc-dia/<slug>/{photos,videos}/, then
moves the completed form into that subfolder (brief.yaml
+ the original). Writes one Dashboard row (state=cho-upload).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./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/.
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 becomesbrief.yamlin 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 to05_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).
See §6. The pipeline runs when media upload finishes (poller detects photos + an existing brief), not at form submit.
brief.yaml exists before intake runs.
Intake reads the folder (brief + photos) and builds the bundle
Sandbox/posts/<slug>/ in the bucket.STATUS.md → moved onto the Dashboard.
STATUS.md still exists inside the bundle as the machine
ledger (state machine + gate findings + override log). The human surface
is the Dashboard; nobody opens STATUS.md.event-folder done → row appears, state=cho-uploadingest/curate/draft done → checklist
pills flip, state=đang-xử-lýQC done → gate_findings populate,
state=chờ-duyệtvideo_edit.py isn't called in the
trial; the pipeline handles photos only.media_triage.py + media_retouch.py) are CPU-local, no LLM —
that's the "free/local/CPU" design. Opus 4.7 enters only at the draft job (7e):
curate = no Opus, draft = Opus. (Having Opus help choose frames is an
optional future upgrade, noted but not built.)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.
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).
decision = approve on the Dashboard and
ticks dignity_ok (a required checkbox — without it the webhook refuses,
forcing the admin to confirm they read the dignity angle).qc-enforcement.md (gate 8 + override section), safeguarding.md
(new §3a), and autonomy-phase.md all add a "Trial-stage (one administrator)"
clause: one person wears both hats; dignity is a required checkbox, not a second
person; automation is still forbidden; reverts to two-person when staff grow.dignity_ok. Phase A stays
absolute: no human click = no pack, no post.caption_fb / caption_web are editable). On
decision = approve, the webhook writes the edited caption back
to copy/facebook.md in the bundle (via the Cloud Run endpoint +
bundle_state), then packs. The admin's edit becomes the official copy in
the workspace.decision = revise (with notes) → bundle back to revising,
the draft job re-runs with the notes. decision = reject → moved to
_rejected.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.
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.
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.
| Component | Form | Trigger | Note |
|---|---|---|---|
| Ring the bell on form submit | Apps Script (bound to Form) | onFormSubmit | only calls the webhook, does NOT create the folder |
event-folder (create subfolder + move form) | Cloud Run Service | webhook from Apps Script (~sec) | naming logic lives here (§6, §7a) |
ingest-watch | Cloud Run Job | Cloud Scheduler 2–5 min | scans Drive, builds the bundle |
| intake→curate→draft→QC | Cloud Run Job(s) | chained after ingest | curate=CPU; draft=Opus 4.7 |
| Push state to Dashboard | inside each job (last step) | on job completion | event-driven, ~sec (§7c) |
status-reconcile (backup) | Cloud Run Job | Cloud Scheduler 15–30 min | only re-syncs failed pushes |
decisions (write back approve/edit) | Cloud Run Service | Apps Script webhook onEdit | verify id-token + nonce |
media_retouch / media_triage | runs inside a job (CPU) | within the job | free/CPU, no separate endpoint |
video_edit | deferred | — | no video yet (7d) |
channel-publisher | no endpoint | — | Phase 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.
| Service | Cost |
|---|---|
| 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.
DONE code complete WIP in progress DEPLOY awaiting administrator LATER not started
<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.)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.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.
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.hmt-publisher SA.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.
Locked (2026-05-29):
phamj.com is a paid Workspace domain; the
API account is registered with billing on (can pay API/token) → Shared Drive,
DWD-if-needed, Anthropic billed directly.<SA-NAME> (§3).hmt-bundles bucket for the trial;
hmt-care later.Remaining — OPERATIONAL (Wave 1 + 2 are no longer code work):
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..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.gcloud auth login
(owner of the GCP project) + confirm the SA key is valid
(py tools/ingest_watch.py --check-connection).pwsh cloud/deploy.ps1
(enables APIs, grants roles, bucket, 2 Services + 2 Jobs, schedulers).SERVICE_URL into the Form script (trigger
onFormSubmit); paste DECISIONS_URL into the Dashboard
script (installable trigger onDashboardEdit — a simple
onEdit will not work).dignity_ok → packed; type posted_id → published.cloud/care/deploy.ps1.