privacyfence-sync 6.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. privacyfence_sync-6.0.0/PKG-INFO +797 -0
  2. privacyfence_sync-6.0.0/README.md +782 -0
  3. privacyfence_sync-6.0.0/pyproject.toml +29 -0
  4. privacyfence_sync-6.0.0/setup.cfg +4 -0
  5. privacyfence_sync-6.0.0/src/privacyfence_sync/__init__.py +8 -0
  6. privacyfence_sync-6.0.0/src/privacyfence_sync/auth.py +189 -0
  7. privacyfence_sync-6.0.0/src/privacyfence_sync/cli.py +136 -0
  8. privacyfence_sync-6.0.0/src/privacyfence_sync/client.py +125 -0
  9. privacyfence_sync-6.0.0/src/privacyfence_sync/recipe.py +343 -0
  10. privacyfence_sync-6.0.0/src/privacyfence_sync/refs.py +84 -0
  11. privacyfence_sync-6.0.0/src/privacyfence_sync/runner.py +227 -0
  12. privacyfence_sync-6.0.0/src/privacyfence_sync/template.py +24 -0
  13. privacyfence_sync-6.0.0/src/privacyfence_sync/transform.py +54 -0
  14. privacyfence_sync-6.0.0/src/privacyfence_sync.egg-info/PKG-INFO +797 -0
  15. privacyfence_sync-6.0.0/src/privacyfence_sync.egg-info/SOURCES.txt +29 -0
  16. privacyfence_sync-6.0.0/src/privacyfence_sync.egg-info/dependency_links.txt +1 -0
  17. privacyfence_sync-6.0.0/src/privacyfence_sync.egg-info/entry_points.txt +2 -0
  18. privacyfence_sync-6.0.0/src/privacyfence_sync.egg-info/requires.txt +8 -0
  19. privacyfence_sync-6.0.0/src/privacyfence_sync.egg-info/scm_file_list.json +25 -0
  20. privacyfence_sync-6.0.0/src/privacyfence_sync.egg-info/scm_version.json +8 -0
  21. privacyfence_sync-6.0.0/src/privacyfence_sync.egg-info/top_level.txt +1 -0
  22. privacyfence_sync-6.0.0/tests/conftest.py +31 -0
  23. privacyfence_sync-6.0.0/tests/test_auth.py +208 -0
  24. privacyfence_sync-6.0.0/tests/test_cli.py +286 -0
  25. privacyfence_sync-6.0.0/tests/test_client.py +174 -0
  26. privacyfence_sync-6.0.0/tests/test_no_daemon_imports.py +45 -0
  27. privacyfence_sync-6.0.0/tests/test_recipe.py +518 -0
  28. privacyfence_sync-6.0.0/tests/test_refs.py +86 -0
  29. privacyfence_sync-6.0.0/tests/test_runner.py +501 -0
  30. privacyfence_sync-6.0.0/tests/test_template.py +33 -0
  31. privacyfence_sync-6.0.0/tests/test_transform.py +105 -0
@@ -0,0 +1,797 @@
1
+ Metadata-Version: 2.4
2
+ Name: privacyfence-sync
3
+ Version: 6.0.0
4
+ Summary: Run a PrivacyFence data-sync recipe as a plain MCP client, with no AI model involved.
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: mcp<3.0.0,>=2.2
9
+ Requires-Dist: httpx2>=2.5
10
+ Requires-Dist: pyyaml>=6
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest; extra == "test"
13
+ Requires-Dist: pytest-asyncio; extra == "test"
14
+ Requires-Dist: pytest-cov; extra == "test"
15
+
16
+ # privacyfence-sync
17
+
18
+ `privacyfence-sync` runs a data-sync recipe against PrivacyFence with no AI model involved. A recipe
19
+ reads data from your connected services (Salesforce reports, Jira searches, Grist tables, files in
20
+ Google Drive), runs your own scripts on the files, writes the result back to Grist, and can publish
21
+ an HTML dashboard on Peek.
22
+
23
+ The intended split of work:
24
+
25
+ - **Designing** a sync is a job for Claude: the Grist data model, which sources to read, how to
26
+ diff and transform them, what the dashboard shows. Claude writes the recipe, the scripts and the
27
+ dashboard template, and you review them like any code change.
28
+ - **Running** the sync on a schedule is a job for `privacyfence-sync`. It makes the same tool calls
29
+ Claude would make, in a fixed order, and runs your scripts between them. It costs no model tokens.
30
+
31
+ It is an ordinary MCP client of PrivacyFence, like Claude Code. Every call goes through
32
+ PrivacyFence's gate, rules and audit log, exactly as Claude's calls do. `privacyfence-sync` never
33
+ holds your Salesforce, Jira, Grist, Drive or Peek credentials, cannot approve anything, and cannot
34
+ change your rules. A scheduled run declares itself unattended, so any step that no auto-accept rule
35
+ covers fails at once instead of waiting for an approval nobody will give.
36
+
37
+ ## Contents
38
+
39
+ - [Install](#install)
40
+ - [Connecting to PrivacyFence](#connecting-to-privacyfence)
41
+ - [The rules a recipe needs](#the-rules-a-recipe-needs)
42
+ - [Recipe format](#recipe-format)
43
+ - [Files and references](#files-and-references)
44
+ - [Step types](#step-types)
45
+ - [Script steps](#script-steps)
46
+ - [Dashboard templates](#dashboard-templates)
47
+ - [Examples](#examples)
48
+ - [Commands](#commands)
49
+ - [Scheduling](#scheduling)
50
+ - [Limits](#limits)
51
+ - [Security notes](#security-notes)
52
+ - [Not covered](#not-covered)
53
+
54
+ ## Install
55
+
56
+ Python 3.11 or newer. Install it in its own virtual environment, together with anything your
57
+ scripts import (for example `openpyxl` to read Excel files):
58
+
59
+ ```sh
60
+ python3 -m venv ~/.venvs/privacyfence-sync
61
+ ~/.venvs/privacyfence-sync/bin/pip install privacyfence-sync openpyxl
62
+ ```
63
+
64
+ Its version matches the PrivacyFence release it ships with. It needs no local PrivacyFence install
65
+ of its own: in organization mode it talks to your organization's server over HTTPS.
66
+
67
+ ## Connecting to PrivacyFence
68
+
69
+ ### Local mode (PrivacyFence on your own computer)
70
+
71
+ 1. Get your AI client token: choose **Copy AI Client Token** in the PrivacyFence companion, or run
72
+ `privacyfence-app --print-mcp-token`. It is the same token Claude Code uses.
73
+ 2. Put it in the environment variable `PRIVACYFENCE_TOKEN` of the process that runs
74
+ `privacyfence-sync`. A token is never accepted as a command-line argument, because other users
75
+ on the machine can read the process list.
76
+ 3. The server address defaults to `http://127.0.0.1:8765/mcp`. If you changed PrivacyFence's port,
77
+ pass `--server` or set it in the recipe.
78
+
79
+ ### Organization mode (a PrivacyFence server)
80
+
81
+ 1. Sign in once:
82
+
83
+ ```sh
84
+ privacyfence-sync login --server https://privacyfence.example.com/mcp
85
+ ```
86
+
87
+ A browser opens on your organization's sign-in page. After you sign in, the tab says "Signed in
88
+ to PrivacyFence" and the terminal prints `Signed in to https://privacyfence.example.com/mcp.`
89
+ 2. Runs then use the stored sign-in automatically, as long as `PRIVACYFENCE_TOKEN` is not set.
90
+ 3. A sign-in lasts 30 days. After that, a run fails with exit code 4 and the message
91
+ `Not signed in to <server>, or the sign-in expired. Run: privacyfence-sync login --server <server>`.
92
+ Sign in again once a month.
93
+
94
+ The sign-in is stored in `~/.config/privacyfence-sync/<hash of the server URL>.json` (or under
95
+ `$XDG_CONFIG_HOME`), readable only by you. `login` listens on `http://127.0.0.1:33418/callback` for
96
+ the browser's return; pass `--callback-port` if that port is taken. The port is registered with
97
+ the server at the first sign-in, so keep using the same one.
98
+
99
+ The server must hand out agent download links. That is the default; if your administrator turned
100
+ off `download_delivery.agent_links`, steps that receive a file fail with a message saying so.
101
+
102
+ ### Unattended sessions must be allowed
103
+
104
+ Both modes need `unattended_sessions.enabled` in PrivacyFence's organization config bundle (built
105
+ with `build_org_bundle.py --enable-unattended-sessions`). Without it, a run stops before the first
106
+ step with:
107
+
108
+ ```
109
+ privacyfence-sync: This PrivacyFence does not allow unattended sessions (unattended_sessions.enabled in org_config.json). Ask your administrator, or run with --attended.
110
+ ```
111
+
112
+ ## The rules a recipe needs
113
+
114
+ A scheduled run gets no approval cards, so every step needs an auto-accept rule, unless the tool is
115
+ one PrivacyFence never asks about (listing tools such as `drive_list_files`). Create rules in
116
+ PrivacyFence's **Settings → Auto-accept → Add a rule**. Each one asks for your passkey.
117
+
118
+ | Recipe step | Scope in Settings | Verbs to tick |
119
+ |---|---|---|
120
+ | `salesforce_export_report`, `salesforce_run_report` | **Salesforce — report**, the report id | read |
121
+ | `jira_export_issues` | **Jira — project**, the project key(s) | search |
122
+ | `drive_download_file` | **Drive — folder**, the folder id the file is in | download |
123
+ | `drive_list_files` | none needed | |
124
+ | `grist_export_records`, `grist_export_sql`, `grist_get_records` | **Grist — document**, the document id | read |
125
+ | `grist_update_csv`, `grist_update_records` | **Grist — document** | update |
126
+ | `grist_import_csv`, `grist_add_records` | **Grist — document** | create |
127
+ | `peek_upload_page` with a `slug` | **Peek — page**, the page's slug | update (new versions) |
128
+
129
+ Notes:
130
+
131
+ - **Personal data.** PrivacyFence scans what a read returns for personal data (names, email
132
+ addresses, phone numbers and similar). When it finds some, it asks a person, and an unattended
133
+ run is denied, even when a rule matches. For the tools whose data goes to a file or a page
134
+ rather than to an AI model, the rule can say otherwise. When you add it, tick:
135
+ - **Exports may include personal data** on a Salesforce report, Jira project or Grist document
136
+ rule (it covers `salesforce_export_report`, `jira_export_issues`, `grist_export_records` and
137
+ `grist_export_sql`);
138
+ - **Downloads may include personal data** on a Drive folder rule (it covers
139
+ `drive_download_file` when the file is delivered as a file; on an organization server a small
140
+ file, up to about 75 KB by default, comes back inside the tool result instead, and is still
141
+ scanned);
142
+ - **New versions may include personal data** on a Peek page rule.
143
+
144
+ Only you can set these boxes, in Settings, behind your passkey; an AI client cannot propose
145
+ them. `salesforce_run_report`, `grist_get_records` and the other reads that return data in the
146
+ tool result are always scanned.
147
+ - **A Jira project rule** covers an export only when every exported issue belongs to an approved
148
+ project. A JQL query that reaches into another project needs that project in the rule too.
149
+ - **A Drive folder rule** covers files whose parent folder is the one named. A file in a subfolder
150
+ needs the subfolder's id.
151
+ - **A Peek page rule** covers new versions of pages that already exist. Publish the page once by
152
+ hand (or through Claude) to get its slug. Creating a page, changing who can see it, renaming or
153
+ deleting it always needs your approval.
154
+ - To find out which step is missing a rule, run the recipe once with `--attended`: each uncovered
155
+ step shows its approval card, with a link, and the card offers the matching "Always allow"
156
+ button where one exists.
157
+
158
+ ## Recipe format
159
+
160
+ A recipe is one YAML file. Paths in it are relative to the recipe file.
161
+
162
+ ```yaml
163
+ version: 1 # required, always 1
164
+ name: sales-mirror # required: lowercase letters, digits and dashes, up to 63
165
+ server: http://127.0.0.1:8765/mcp # optional, see "Commands" for the order of precedence
166
+ work_dir: work # optional, default "work"
167
+ steps: # required, 1 to 50 steps, run in order
168
+ - id: opps
169
+ tool: salesforce_export_report
170
+ args:
171
+ report_id: 00O000000000001
172
+ cell_format: both
173
+ file_name: opps.csv
174
+ ```
175
+
176
+ Every step has an `id` (lowercase letters, digits, `-` and `_`, up to 40 characters, unique in
177
+ the recipe) and exactly one of `tool`, `run` or `template`. A step may only refer to steps above
178
+ it. Any unknown key is an error, so a typo fails `check` instead of being ignored.
179
+
180
+ **The work folder** (`work_dir`) is where every file of a run lands. It is created if needed.
181
+ Files from the previous run are overwritten, not deleted, and stay there between runs. They hold
182
+ the data you read, so keep the folder somewhere only you can read. Your scripts may keep their own
183
+ state anywhere else you choose (for example a "last committed load" folder); `privacyfence-sync`
184
+ does not touch it.
185
+
186
+ **Tool arguments** are the tool's own arguments, as listed in PrivacyFence's
187
+ [tools reference](https://github.com/privacyfence/privacyfence/blob/main/docs/tools-reference.md).
188
+ Several tools take JSON as text (`filters`, `filter`, `args`, `fields`). In a recipe you can write
189
+ those as a YAML list or mapping; `privacyfence-sync` sends any list or mapping value as JSON text.
190
+ Strings, numbers and `true`/`false` are sent as they are. `reason` is optional: by default each call
191
+ says `privacyfence-sync <recipe name>: step <id>`, which is what the audit log and any approval
192
+ card show.
193
+
194
+ ## Files and references
195
+
196
+ Steps pass files to each other by name. These steps produce files:
197
+
198
+ | Step | Its files | Referred to as |
199
+ |---|---|---|
200
+ | An export tool | `args.file_name` | `<step id>` |
201
+ | `drive_download_file` | `save_as` | `<step id>` |
202
+ | Any other tool step with `save_result` | the tool's result, as JSON | `<step id>` |
203
+ | A script step | each entry of `outputs`, and `stdout` if given | `<step id>.<output name>` |
204
+ | A template step | `output` | `<step id>` |
205
+
206
+ Two kinds of reference use them:
207
+
208
+ - **A file reference** (`file:` on an upload step, `data:` on a template step, and `{file:...}` in
209
+ a script step's arguments) names a step, or a script step's output: `file: plan.upsert`.
210
+ - **A value reference** reads one value out of an earlier JSON file and puts it into a tool
211
+ argument or a script argument: `${pick.stdout:pick.drive_id}` is the value at the path
212
+ `pick` → `drive_id` in the `stdout` output of step `pick`. Path parts are object keys or list
213
+ indexes (`files.0.id`). When a value reference is the whole string, the value keeps its JSON
214
+ type (a number stays a number); inside a longer string it is inserted as text. A path that does
215
+ not exist fails the step before anything is called.
216
+
217
+ ## Step types
218
+
219
+ ### Export steps
220
+
221
+ `salesforce_export_report`, `jira_export_issues`, `grist_export_records`, `grist_export_sql`.
222
+
223
+ ```yaml
224
+ - id: issues
225
+ tool: jira_export_issues
226
+ args:
227
+ jql: project = OPS AND updated >= -30d
228
+ fields: ["Story Points", "duedate"]
229
+ file_name: issues.csv
230
+ ```
231
+
232
+ - `file_name` is required. It must be a plain file name ending in `.csv` or `.json`, which picks
233
+ the format. The file lands in the work folder under that name.
234
+ - Don't give `destination_dir`: `privacyfence-sync` sets it.
235
+ - How the file arrives depends on the install, and the recipe doesn't change: a PrivacyFence that
236
+ can write to your folders writes it there; a privilege-separated local install or an
237
+ organization server hands out a one-time download link, which `privacyfence-sync` fetches at
238
+ once (the link expires after 5 minutes). Either way the file's SHA-256 is checked against what
239
+ PrivacyFence reported, and the step fails if they differ.
240
+ - A `jira_export_issues` query that matches no issue writes no file on PrivacyFence's side;
241
+ `privacyfence-sync` writes a CSV with just the header row (or `[]` for `.json`), so later steps
242
+ still find their input.
243
+
244
+ What each export writes:
245
+
246
+ | Tool | Columns | Notes |
247
+ |---|---|---|
248
+ | `salesforce_export_report` | the report's detail columns, or `columns` | `cell_format: value` (default) writes raw values: dates as YYYY-MM-DD, amounts as numbers, record ids for lookups. `label` writes what Salesforce shows. `both` writes, for each column `C`, the raw value as `C` and the shown text as `C.label`, plus `C.currency` for money columns. A report over 2,000 rows or a grouped report needs `page_by`, a column unique per row. |
249
+ | `jira_export_issues` | key, summary, status, issue_type, priority, assignee, reporter, labels, created, updated, url, then `fields` | People are display names. Lists (labels, multi-select fields) are JSON in a CSV cell. |
250
+ | `grist_export_records` | every column of the table, including formula columns | `filter` narrows rows by column values. |
251
+ | `grist_export_sql` | what the `SELECT` returns | Cells are as Grist stores them: dates as seconds since 1970, true/false as 1/0. Only people who can copy the whole document may run SQL; others get a message pointing to `grist_export_records`. |
252
+
253
+ The report totals (the aggregates at the bottom of a Salesforce report) are not in the export. To
254
+ check your load against them, add a `salesforce_run_report` step with `summary_only: true` and
255
+ `save_result`; it returns the groupings and totals, no rows.
256
+
257
+ ### Download steps
258
+
259
+ `drive_download_file`, for a file such as an Excel workbook in Google Drive.
260
+
261
+ ```yaml
262
+ - id: workbook
263
+ tool: drive_download_file
264
+ args:
265
+ file_id: "${pick.stdout:pick.drive_id}"
266
+ save_as: workbook.xlsx
267
+ ```
268
+
269
+ - `save_as` is required: the plain file name the download gets in the work folder, whatever the
270
+ file is called in Drive.
271
+ - Don't give `destination_dir`: `privacyfence-sync` sets it.
272
+ - The file arrives like an export's (written in place, or by one-time link), or, on an
273
+ organization server, inside the tool result for a small file; `privacyfence-sync` handles all
274
+ three.
275
+
276
+ ### Tool steps that save their result
277
+
278
+ Any tool step except an export or download step (whose file is already its result) can keep the
279
+ tool's result as a JSON file for later steps. The name must end in `.json`:
280
+
281
+ ```yaml
282
+ - id: listing
283
+ tool: drive_list_files
284
+ args:
285
+ query: "'1AbCdEfGhIjKlMnOpQrStUvWxYz' in parents and trashed = false"
286
+ max_results: 200
287
+ save_result: listing.json
288
+ ```
289
+
290
+ Use it for a listing a script picks from, for report totals, or to check a write's counts
291
+ afterwards. Remember that a tool result is what an AI model would see: tools that return rows
292
+ (`grist_get_records`, `salesforce_run_report` without `summary_only`) are always PII-scanned and
293
+ capped in size. For bulk data, use an export.
294
+
295
+ ### Upload steps
296
+
297
+ `grist_update_csv`, `grist_import_csv`, `peek_upload_page`.
298
+
299
+ ```yaml
300
+ - id: load
301
+ tool: grist_update_csv
302
+ file: plan.upsert
303
+ skip_if_empty: true
304
+ args:
305
+ doc_id: 8CAN8gKdxY7z
306
+ table_id: Deals
307
+ key_column: Deal_Id
308
+ create_missing: true
309
+ clear_empty: true
310
+ save_result: load_result.json
311
+ ```
312
+
313
+ - `file` names an earlier file; `privacyfence-sync` uploads it through a one-time upload slot and
314
+ passes its `upload_id`. Don't give `local_path`, `upload_id` or `html`.
315
+ - `skip_if_empty: true` skips the step when the file has no data rows (a CSV with no row after
316
+ the header that has a non-empty cell, or a JSON `[]`). Without it, `grist_update_csv` and `grist_import_csv` refuse such a file and the
317
+ run fails. A skipped step with `save_result` writes `{"skipped": true}` there, so later steps can
318
+ tell.
319
+ - `grist_update_csv` matches each CSV row to the record whose `key_column` has the same value,
320
+ changes only the cells that differ, and with `create_missing: true` adds rows that match nothing.
321
+ The key column must be in the CSV header and unique in the table. An empty cell leaves the value
322
+ unchanged, unless `clear_empty: true`, in which case it clears it. Columns missing from the CSV
323
+ are never touched.
324
+ - `grist_import_csv` adds every row as a new record.
325
+ - `peek_upload_page` with `slug` uploads a new version of that page; its visibility stays as it is.
326
+
327
+ ### Other tool steps
328
+
329
+ Any other PrivacyFence tool can be a step, called with its `args`, for example
330
+ `grist_add_records`. It produces no file unless it has `save_result`. It needs a rule like any
331
+ other step.
332
+
333
+ ### Script steps
334
+
335
+ ```yaml
336
+ - id: plan
337
+ run: scripts/datalake.py
338
+ args: [plan, deals, --home, /Users/me/datalake, --source, "{file:opps}", --out, "{work_dir}/plan"]
339
+ outputs:
340
+ upsert: plan/upsert.csv
341
+ flag_out: plan/flag_out.csv
342
+ summary: plan/summary.json
343
+ timeout_seconds: 600 # optional, 1 to 3600, default 600
344
+ ```
345
+
346
+ Runs your Python script. See [Script steps](#script-steps) below.
347
+
348
+ ### Template steps
349
+
350
+ ```yaml
351
+ - id: page
352
+ template: templates/dashboard.html
353
+ data: dashboard_data # a file ending in .json
354
+ output: dashboard.html
355
+ ```
356
+
357
+ Puts a JSON file into an HTML template. See [Dashboard templates](#dashboard-templates).
358
+
359
+ ## Script steps
360
+
361
+ A script step runs an ordinary Python script with the same Python as `privacyfence-sync`:
362
+
363
+ ```
364
+ python <run> <args...>
365
+ ```
366
+
367
+ - **Arguments.** `args` is a list of strings. In each one, `{file:<ref>}` becomes that file's full
368
+ path, `{work_dir}` the work folder, `{recipe_dir}` the recipe's folder, `{output:<name>}` the
369
+ path of one of this step's own `outputs`, and a value reference `${<ref>:<path>}` the value
370
+ (as text). Any other `{name}` or `{name:...}` is an error, caught by `check`, because it is
371
+ almost always a typo; other text, JSON included, is passed as written. Arguments go to the script
372
+ directly, never through a shell.
373
+ - **Outputs.** `outputs` maps names (lowercase letters, digits, `-` and `_`) to paths inside the
374
+ work folder (a subfolder is fine, `..` is not). After the script exits with 0, every declared output must exist. `stdout: <file name>`
375
+ also saves what the script prints, as output `stdout`; the printed text is shown in the run's
376
+ log either way.
377
+ - **Exit codes.**
378
+ - `0`: the step succeeded; the run continues.
379
+ - `10`: there is nothing to do this time (for example no new source file). The run stops here
380
+ and `privacyfence-sync` exits with 0, printing `[<id>] nothing to do, run stopped`.
381
+ - Anything else: the step failed and the run stops with exit code 2. The last 2,000 characters
382
+ of the script's error output are shown.
383
+ - **Guards.** A script is also how a recipe stops before writing when something looks wrong. Put a
384
+ small check after your plan step: it reads the plan's summary and exits non-zero on warnings, a
385
+ row count that moved more than you expect, or an empty parse. Nothing after it runs, and a
386
+ person looks at it.
387
+ - **Environment.** The script runs in the work folder and gets your environment **without** any
388
+ variable whose name starts with `PRIVACYFENCE_`, so it never sees the token. It has no
389
+ PrivacyFence access: everything it needs comes in as files, and everything it produces goes out
390
+ as files. It may read and write your own files elsewhere (state, run history).
391
+ - The script's own folder is on its import path, so it can import modules that sit next to it.
392
+ The work folder is not, so a file that lands there is never imported by accident.
393
+ - `run` must be inside the recipe's folder. Keep the recipe, its scripts and templates in one
394
+ folder (or one git repository).
395
+
396
+ ### Rows deleted at the source
397
+
398
+ PrivacyFence's Grist connector never deletes a record. A row that disappears from the source
399
+ therefore stays in Grist. Keep a Bool flag column such as `In_Report` in the table, diff the source
400
+ against your last committed load (or against an export of the Grist table), and write the flag as
401
+ `false` for every key the source no longer has. Dashboards filter on the flag, and history
402
+ survives.
403
+
404
+ ## Dashboard templates
405
+
406
+ A template is an HTML file with this placeholder exactly once:
407
+
408
+ ```html
409
+ <script>
410
+ const DATA = /*PRIVACYFENCE_SYNC_DATA*/null;
411
+ // ... draw the dashboard from DATA ...
412
+ </script>
413
+ ```
414
+
415
+ The template step replaces the placeholder with the contents of the `data` JSON file. The data is
416
+ escaped so it can never close the `<script>` element, whatever text it holds. The JSON can come
417
+ from a `grist_export_sql` export with a `.json` file name (a list of objects, one per row), or from
418
+ a script step that shapes it exactly as the template expects.
419
+
420
+ Claude writes the template once, when the dashboard is designed; each run only refreshes `DATA`.
421
+ `grist_export_sql` writes dates as seconds since 1970 and true/false as 1/0, so convert them in the
422
+ template's script or in a script step.
423
+
424
+ The page should be self-contained (inline CSS and JavaScript, or libraries from a CDN your Peek
425
+ viewers can reach). Peek takes pages up to 10 MB.
426
+
427
+ ## Examples
428
+
429
+ Two recipes in the shape of a real datalake: one engine script (`scripts/datalake.py`) that parses
430
+ a source, diffs it against the last committed load, writes the CSVs for Grist, and commits the load
431
+ once Grist has accepted it.
432
+
433
+ ### A Salesforce report mirrored into Grist
434
+
435
+ ```yaml
436
+ version: 1
437
+ name: sf-opportunities
438
+ steps:
439
+ - id: report
440
+ tool: salesforce_export_report
441
+ args: {report_id: 00O000000000001, cell_format: both, file_name: opps.csv}
442
+
443
+ - id: totals
444
+ tool: salesforce_run_report
445
+ args: {report_id: 00O000000000001, summary_only: true}
446
+ save_result: totals.json
447
+
448
+ - id: plan
449
+ run: scripts/datalake.py
450
+ args: [plan, sf-opportunities, --home, /Users/me/datalake, --source, "{file:report}",
451
+ --totals, "{file:totals}", --out, "{work_dir}/plan"]
452
+ outputs: {upsert: plan/upsert.csv, flag_out: plan/flag_out.csv, summary: plan/summary.json,
453
+ sync_log: plan/sync_log.csv}
454
+
455
+ - id: guard
456
+ run: scripts/guard.py
457
+ args: ["{file:plan.summary}", --max-change, "0.15", --max-flagged-out, "0.10"]
458
+
459
+ - id: upsert
460
+ tool: grist_update_csv
461
+ file: plan.upsert
462
+ skip_if_empty: true
463
+ args: {doc_id: 8CAN8gKdxY7z, table_id: Opportunities, key_column: Opportunity_Id,
464
+ create_missing: true, clear_empty: true}
465
+ save_result: upsert_result.json
466
+
467
+ - id: flag_out
468
+ tool: grist_update_csv
469
+ file: plan.flag_out
470
+ skip_if_empty: true
471
+ args: {doc_id: 8CAN8gKdxY7z, table_id: Opportunities, key_column: Opportunity_Id}
472
+ save_result: flag_out_result.json
473
+
474
+ - id: verify
475
+ run: scripts/datalake.py
476
+ args: [verify, "{file:plan.summary}", "{file:upsert}", "{file:flag_out}"]
477
+
478
+ - id: commit
479
+ run: scripts/datalake.py
480
+ args: [commit, sf-opportunities, --home, /Users/me/datalake, --run, "{work_dir}/plan"]
481
+
482
+ - id: sync_log
483
+ tool: grist_import_csv
484
+ file: plan.sync_log
485
+ args: {doc_id: 8CAN8gKdxY7z, table_id: Sync_Log}
486
+ ```
487
+
488
+ ### The newest Excel workbook in a Drive folder
489
+
490
+ ```yaml
491
+ version: 1
492
+ name: cashflow
493
+ steps:
494
+ - id: listing
495
+ tool: drive_list_files
496
+ args: {query: "'1AbCdEfGhIjKlMnOpQrStUvWxYz' in parents and trashed = false", max_results: 200}
497
+ save_result: listing.json
498
+
499
+ - id: pick
500
+ run: scripts/datalake.py
501
+ args: [pick, cashflow, --home, /Users/me/datalake, --listing, "{file:listing}", --exit-10-if-current]
502
+ stdout: pick.json
503
+
504
+ - id: workbook
505
+ tool: drive_download_file
506
+ args: {file_id: "${pick.stdout:pick.drive_id}"}
507
+ save_as: cashflow.xlsx
508
+
509
+ - id: plan
510
+ run: scripts/datalake.py
511
+ args: [plan, cashflow, --home, /Users/me/datalake, --source, "{file:workbook}",
512
+ --source-info, "${pick.stdout:source_info_arg}", --out, "{work_dir}/plan"]
513
+ outputs: {upsert: plan/upsert.csv, flag_out: plan/flag_out.csv, summary: plan/summary.json}
514
+
515
+ - id: guard
516
+ run: scripts/guard.py
517
+ args: ["{file:plan.summary}"]
518
+
519
+ - id: upsert
520
+ tool: grist_update_csv
521
+ file: plan.upsert
522
+ skip_if_empty: true
523
+ args: {doc_id: 9DbO9hLeyZ8a, table_id: Cashflow_Revenue, key_column: Opportunity_Id,
524
+ create_missing: true, clear_empty: true}
525
+
526
+ - id: flag_out
527
+ tool: grist_update_csv
528
+ file: plan.flag_out
529
+ skip_if_empty: true
530
+ args: {doc_id: 9DbO9hLeyZ8a, table_id: Cashflow_Revenue, key_column: Opportunity_Id}
531
+
532
+ - id: commit
533
+ run: scripts/datalake.py
534
+ args: [commit, cashflow, --home, /Users/me/datalake, --run, "{work_dir}/plan"]
535
+ ```
536
+
537
+ Here `pick` prints its JSON and exits with 10 when the newest workbook is the one already loaded, so
538
+ a run with nothing new stops after two cheap steps.
539
+
540
+ Rules for these: Salesforce report `00O000000000001` (read), Drive folder `1AbCd…` (download), and
541
+ each Grist document (update, create), with the personal-data boxes where the data holds names.
542
+
543
+ ### Publishing a dashboard
544
+
545
+ ```yaml
546
+ version: 1
547
+ name: pipeline-dashboard
548
+ steps:
549
+ - id: data
550
+ tool: grist_export_sql
551
+ args:
552
+ doc_id: 8CAN8gKdxY7z
553
+ sql: >-
554
+ SELECT Stage, COUNT(*) AS deals, SUM(Amount_USD) AS amount
555
+ FROM Opportunities WHERE In_Report = 1 GROUP BY Stage
556
+ file_name: data.json
557
+
558
+ - id: page
559
+ template: templates/dashboard.html
560
+ data: data
561
+ output: dashboard.html
562
+
563
+ - id: publish
564
+ tool: peek_upload_page
565
+ file: page
566
+ args: {slug: pipeline-dashboard}
567
+ ```
568
+
569
+ Before the first scheduled run of any recipe:
570
+
571
+ ```sh
572
+ privacyfence-sync check recipe.yaml
573
+ privacyfence-sync run recipe.yaml --attended # approve, and add any missing rule
574
+ privacyfence-sync run recipe.yaml # unattended: no card should appear
575
+ ```
576
+
577
+ ## Commands
578
+
579
+ ```
580
+ privacyfence-sync run RECIPE [--server URL] [--attended] [--token-env NAME]
581
+ privacyfence-sync login --server URL [--callback-port N]
582
+ privacyfence-sync check RECIPE
583
+ ```
584
+
585
+ - **`run`** runs every step in order and stops at the first failure. Each step prints a line
586
+ starting with `[<step id>]`.
587
+ - `--attended` skips the unattended declaration. A step that needs approval prints
588
+ `[<id>] waiting for approval: <link>`, waits for your decision (up to 16 minutes), and repeats
589
+ the call once you approve. Use it for a first run and to find missing rules.
590
+ - `--token-env NAME` reads the token from the variable `NAME` instead of `PRIVACYFENCE_TOKEN`.
591
+ - Without a token in the environment, `run` uses the stored sign-in from `login`.
592
+ - **`login`** signs in to an organization server, then checks the connection.
593
+ - **`check`** validates the recipe, its references and its paths, and prints
594
+ `<name>: <n> step(s), OK`. It makes no network call and runs no script.
595
+
596
+ The server address is, in order: `--server`, the recipe's `server`, the variable
597
+ `PRIVACYFENCE_MCP_URL`, then `http://127.0.0.1:8765/mcp`.
598
+
599
+ Exit codes:
600
+
601
+ | Code | Meaning |
602
+ |---|---|
603
+ | 0 | Every step succeeded, or a script step said there was nothing to do (exit 10) |
604
+ | 1 | The recipe, a command-line argument, a reference or a template is wrong |
605
+ | 2 | A tool call, a file transfer or a script failed, or the server refused the connection |
606
+ | 3 | PrivacyFence denied a step (in an unattended run: no rule covers it) |
607
+ | 4 | Not signed in, the sign-in expired, or no token |
608
+
609
+ Every error prints one line starting with `privacyfence-sync:` to the error output; when a script
610
+ failed, the script's own error output is shown just above it. No message ever contains a token.
611
+
612
+ ## Scheduling
613
+
614
+ Run recipes as your own user, the same user PrivacyFence's token belongs to. Keep one recipe per
615
+ source, so a failure in one source doesn't hold up the others, and give each recipe its own folder
616
+ with its scripts, templates and work folder. `run` and `template` paths must stay inside the
617
+ recipe's folder, and the default `work_dir: work` then lands next to the recipe. Group the folders
618
+ by schedule and number them for the order they run in, with the dashboard last:
619
+
620
+ ```
621
+ ~/datalake/ a git repository
622
+ .gitignore work/ state/ logs/
623
+ daily/
624
+ 10-sf-opportunities/
625
+ recipe.yaml
626
+ scripts/plan.py
627
+ work/ this recipe's run files (not in git)
628
+ 20-drive-cashflow/
629
+ recipe.yaml
630
+ scripts/...
631
+ 90-dashboard/
632
+ recipe.yaml
633
+ templates/dashboard.html
634
+ hourly/
635
+ ...
636
+ state/ your scripts' own state, e.g. state/sf-opportunities/
637
+ logs/
638
+ ```
639
+
640
+ One wrapper runs every recipe of a schedule, so adding a recipe means adding a folder, not another
641
+ scheduler entry. Recipes hand data to each other through Grist, not through each other's work
642
+ folders. Code several recipes share can't be linked in, since a symbolic link that points outside
643
+ the recipe's folder is refused; make it a small package and install it into the same virtual
644
+ environment (`pip install -e ~/datalake/lib`), and keep the scripts in each folder thin.
645
+ Keep `~/datalake` out of `~/Documents`, `~/Desktop` and `~/Downloads` on macOS: a background job
646
+ is not allowed to read them.
647
+
648
+ ### macOS: launchd and the Keychain
649
+
650
+ Use a launchd agent, not cron. A launchd calendar job that was due while the Mac slept runs once
651
+ when it wakes; cron skips it. (Neither runs a job that was due while the Mac was shut down.)
652
+
653
+ Keep each recipe's token in your login Keychain, under the recipe's folder name. `-U` replaces an
654
+ existing item, so the same command renews an expired token; `-w` with no value asks for the token,
655
+ so it stays out of your shell history:
656
+
657
+ ```sh
658
+ security add-generic-password -U -s privacyfence-sync -a 10-sf-opportunities -T /usr/bin/security -w
659
+ ```
660
+
661
+ `-T /usr/bin/security` lets the wrapper read the item without a prompt. One token for all recipes
662
+ works too; store it under each name, or change the lookup below.
663
+
664
+ The wrapper, `~/bin/pf-sync.sh` (`chmod 700`), runs every recipe of a schedule with its own token,
665
+ carries on after a failure, and shows a notification naming the recipes that failed:
666
+
667
+ ```zsh
668
+ #!/bin/zsh
669
+ set -u
670
+ group="${1:-daily}"
671
+ S="$HOME/.venvs/privacyfence-sync/bin/privacyfence-sync"
672
+ cd "$HOME/datalake" || exit 1
673
+
674
+ # Just after wake, give PrivacyFence up to two minutes to come up.
675
+ for i in {1..24}; do /usr/bin/curl -s -o /dev/null http://127.0.0.1:8765/mcp && break; sleep 5; done
676
+
677
+ failed=()
678
+ for recipe in $group/*/recipe.yaml(N); do
679
+ name="${recipe:h:t}"
680
+ token="$(/usr/bin/security find-generic-password -s privacyfence-sync -a "$name" -w 2>/dev/null)" \
681
+ || { echo "$(date) $name: no token in the Keychain"; failed+="$name(no token)"; continue; }
682
+ echo "$(date) start $name"
683
+ PRIVACYFENCE_TOKEN="$token" "$S" run "$recipe"
684
+ rc=$?
685
+ echo "$(date) end $name rc=$rc"
686
+ [ $rc -ne 0 ] && failed+="$name($rc)"
687
+ done
688
+
689
+ if (( ${#failed} )); then
690
+ /usr/bin/osascript -e "display notification \"${failed[*]}\" with title \"privacyfence-sync: $group\""
691
+ exit 2
692
+ fi
693
+ ```
694
+
695
+ The agent, `~/Library/LaunchAgents/local.pf-sync.daily.plist`, runs it every day at 06:10. launchd
696
+ does not expand `~` or `$HOME`, so write your home folder out, and create the log folder first:
697
+
698
+ ```xml
699
+ <?xml version="1.0" encoding="UTF-8"?>
700
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
701
+ <plist version="1.0">
702
+ <dict>
703
+ <key>Label</key><string>local.pf-sync.daily</string>
704
+ <key>ProgramArguments</key>
705
+ <array><string>/Users/you/bin/pf-sync.sh</string><string>daily</string></array>
706
+ <key>StartCalendarInterval</key>
707
+ <dict><key>Hour</key><integer>6</integer><key>Minute</key><integer>10</integer></dict>
708
+ <key>StandardOutPath</key><string>/Users/you/datalake/logs/daily.log</string>
709
+ <key>StandardErrorPath</key><string>/Users/you/datalake/logs/daily.log</string>
710
+ </dict>
711
+ </plist>
712
+ ```
713
+
714
+ For weekdays only, make `StartCalendarInterval` an array of five such dicts, each with a `Weekday`
715
+ key (1 to 5). Load it, and run it once now to check it:
716
+
717
+ ```sh
718
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.pf-sync.daily.plist
719
+ launchctl kickstart -p gui/$(id -u)/local.pf-sync.daily
720
+ ```
721
+
722
+ `launchctl bootout gui/$(id -u)/local.pf-sync.daily` turns it off. The job runs only while you are
723
+ logged in (a locked screen is fine), which is also when the Keychain and a local PrivacyFence are
724
+ available.
725
+
726
+ ### Linux: cron
727
+
728
+ cron does not catch up on a run that was due while the machine was off or asleep. Keep the token
729
+ out of the crontab line, for example in a wrapper script only you can read:
730
+
731
+ ```sh
732
+ #!/bin/sh
733
+ # ~/bin/datalake-sync.sh, chmod 700
734
+ export PRIVACYFENCE_TOKEN='...'
735
+ S=~/.venvs/privacyfence-sync/bin/privacyfence-sync
736
+ status=0
737
+ for r in ~/datalake/daily/*/recipe.yaml; do
738
+ "$S" run "$r" || status=1
739
+ done
740
+ exit $status
741
+ ```
742
+
743
+ ```
744
+ # crontab -e: every weekday at 06:10
745
+ 10 6 * * 1-5 ~/bin/datalake-sync.sh >> ~/datalake/last-run.log 2>&1
746
+ ```
747
+
748
+ The exit code tells your scheduler or monitoring what happened (see [Commands](#commands)). A run
749
+ that exits 3 means a rule is missing, or a step's data changed so that a rule no longer covers it,
750
+ for example a new project appearing in a Jira export or a file moved to another Drive folder.
751
+
752
+ ## Limits
753
+
754
+ These come from PrivacyFence's tools, not from `privacyfence-sync`:
755
+
756
+ | What | Limit |
757
+ |---|---|
758
+ | `grist_export_records`, `grist_export_sql` | 50,000 rows per export |
759
+ | `salesforce_export_report` | 2,000 rows without `page_by`; with it, up to `salesforce.report_max_pages` runs (50 by default) |
760
+ | `jira_export_issues` | 10,000 issues per export; narrow the JQL and export in parts above that |
761
+ | Any delivered file | 200 MB (`file_bridge.max_download_bytes`) |
762
+ | `grist_update_csv`, `grist_import_csv` | 20,000 rows, 100 columns, 10,000 characters per cell, 50 MB per file; one key column; formula columns can't be written |
763
+ | Upload slot | 50 MB, valid 10 minutes |
764
+ | `peek_upload_page` | 10 MB per page |
765
+ | Download link | one use, 5 minutes |
766
+ | Grist writes | add and update only, never delete (see [Rows deleted at the source](#rows-deleted-at-the-source)) |
767
+
768
+ Grist writes are not atomic. If one fails part-way, the earlier chunks are in the table; running
769
+ `grist_update_csv` again with the same file finishes the rest, which is why a commit step belongs
770
+ after the writes, not before.
771
+
772
+ ## Security notes
773
+
774
+ - `privacyfence-sync` imports nothing from PrivacyFence and has no access to its credentials,
775
+ rules or approvals. It can only make the calls your rules allow, like any AI client.
776
+ - The token (or the stored sign-in) is the key to everything your rules allow without a card.
777
+ Treat it like a password: environment variables or a file only you can read, never a recipe,
778
+ a crontab line or a shared folder.
779
+ - Files are written to the work folder in plain text. They hold whatever the source holds,
780
+ personal data included when your rule allows it.
781
+ - Scripts and templates run with your user's rights. Review them like any code; a recipe folder in
782
+ git makes every change a diff you can read.
783
+ - Every call is in PrivacyFence's audit log with the recipe and step name in its reason. A call that
784
+ auto-accepted with personal data in it is logged as auto accepted, with the rule's id and the
785
+ personal-data categories found.
786
+
787
+ ## Not covered
788
+
789
+ - **No branching beyond stopping.** A script can stop a run successfully (exit 10) or fail it, and
790
+ an upload can be skipped when its file is empty. There are no conditions or loops in a recipe.
791
+ - **No retries.** A failed step stops the run; the next scheduled run starts again from the top.
792
+ A script that commits state only after the writes makes that safe.
793
+ - **No parallel steps.** Steps run one after another.
794
+ - **No new Peek pages and no new Grist tables.** A recipe publishes new versions of an existing page
795
+ and writes to existing tables. Creating them is a one-time design step, done with Claude.
796
+ - **No per-row tool calls.** A recipe can't call a tool once per row of a file. Bulk writes go
797
+ through `grist_update_csv` and `grist_import_csv`; for clearing values, use `clear_empty`.