Redaction Is Not Omission: Publishing Private Work in a Public Coding-Stats Card

I wanted to publish a tiny SVG card showing where my coding time went during the last seven days.
Languages were easy. Projects were not.
A public card might contain rows such as:
open-source-tool 8h 10m
Unknown Project 6h 40m
personal-site 2h 15m
The private project's real name is already absent in this example.
WakaTime has supplied the placeholder Unknown Project.
So the privacy question is no longer only, “Did I reveal the name?”
It is also:
- Should the public card reveal that private work exists?
- Should it reveal how much time it occupied?
- Should it keep that work in the ranking?
- If I remove it, what replaces it?
- Does removing it change every other bar?
That is where a seemingly harmless display option turns into a data-contract decision.
In my wakatime-svg generator, I ended up supporting both replacement and omission.
They are not two spellings of the same privacy feature.
They publish different datasets.
Replacement hides identity but preserves the event
The default behavior replaces Unknown Project with Private project.
open-source-tool 8h 10m
Private project 6h 40m
personal-site 2h 15m
The name is generic, but the row still carries information.
It says that private work existed. It keeps the duration. It keeps the row's relative position. It contributes to the visual distribution.
That can be exactly what I want.
If the card is meant to answer “How was my coding time distributed?”, deleting a large block of private work would make the answer look cleaner and less true.
Replacement lets me say, “This work happened, but its identity is not part of the public artifact.”
The implementation keeps selection and display naming separate:
def resolve_project_display_name(raw_name, private_label):
safe_name = (raw_name or "").strip()
if is_unknown_project_name(safe_name):
if private_label is None:
return "Unknown Project"
return private_label
return safe_name
No row is removed here. Only the label changes.
That matters because redaction should be explicit about what it redacts. This function hides the display identity. It does not hide existence, time, rank, or proportion.
Omission changes the ranking itself
The generator also supports:
WAKATIME_SKIP_UNKNOWN_PROJECTS=true
That path filters unknown rows before selecting the top N projects.
def prepare_project_items(items, limit, skip_unknown):
selected = []
for item in items:
raw_name = (item.get("name") or "").strip()
if skip_unknown and is_unknown_project_name(raw_name):
continue
selected.append(item)
if len(selected) >= max(1, limit):
break
return selected
Notice the order.
The function does not take the first five rows and then delete a private one, leaving four. It skips the private row while walking the source ranking and pulls in the next public project until it reaches the limit.
With a limit of three, this source list:
1. open-source-tool
2. Unknown Project
3. personal-site
4. documentation
becomes:
1. open-source-tool
2. personal-site
3. documentation
The card is still full. It is also no longer the same top-three distribution.
That is not a bug. It is the point of omission.
But calling both modes “hide private projects” would conceal a meaningful difference from the person configuring the card.
Removing one row can change every bar
There is a second-order effect that is easy to miss.
Project bars in this generator are scaled against the busiest selected project:
busiest = max((parse_total_seconds(item) for item in items), default=0.0)
percent = seconds / busiest * 100.0 if busiest > 0 else 0.0
Consider this synthetic input:
Unknown Project 100 minutes
alpha 80 minutes
beta 40 minutes
With replacement and a two-row limit, the card keeps the first two rows:
Private project 100%
alpha 80%
With omission, the private row disappears and the next public project enters:
alpha 100%
beta 50%
The public project's underlying 80 minutes did not change. Its visual bar changed from 80% to 100% because the comparison set changed.
This is why omission cannot be described as “replacement with an empty string.” It changes membership, rank, and normalization.
The visualization is still valid, but it answers a narrower question:
How is time distributed among the public rows I chose to show?
Replacement answers a different one:
How is all reported project time distributed when private identities are generalized?
Neither question is universally better. The dangerous part is pretending they are the same question.
A boolean was not enough for the label policy
The action exposes two separate inputs.
WAKATIME_PRIVATE_PROJECT_LABEL controls naming:
- unset, empty, or
true→Private project false→ preserve WakaTime'sUnknown Project- any other string → use that string as the replacement label
WAKATIME_SKIP_UNKNOWN_PROJECTS=true controls membership:
- remove unknown rows
- continue through the source ranking
- fill the card with the next eligible projects
This is slightly more configuration than one hidePrivateProjects boolean.
It is also more honest.
A single boolean would have to make two decisions at once:
- whether the row remains in the dataset
- what label the row uses if it remains
Those decisions have different privacy and analytical consequences, so they deserve different controls.
There is one caution with custom labels: customization can undo redaction.
Client project, Confidential work, or Private project stay generic.
A customer name, internal codename, or unreleased product name does not.
The code cannot decide whether an arbitrary custom string is sensitive. That responsibility remains with the operator configuring the public artifact.
Treat the placeholder as a boundary, not a project name
The generator considers both an empty name and a case-insensitive Unknown Project value unknown:
def is_unknown_project_name(raw_name):
safe_name = (raw_name or "").strip()
if not safe_name:
return True
return safe_name.lower() == "Unknown Project".lower()
This helper does not try to infer privacy from arbitrary project names.
That restraint matters.
A heuristic such as “hide anything containing client” would create two new problems:
- false positives that remove legitimate public projects
- false negatives that leave sensitive names untouched
The upstream placeholder is the boundary the generator actually understands. Anything beyond that requires an explicit policy from the person publishing the card.
The test matrix should describe the policy
At the current source revision, tests/test_project_rows.py verifies that project bars track time proportionally and that a zero-duration row renders an empty bar.
It does not directly lock the replacement-versus-omission matrix.
That is a testing gap, because the privacy behavior lives in the interaction of three functions:
parse_private_project_label_env()prepare_project_items()build_project_rows()
The smallest useful contract test would use synthetic data and cover:
default label → row preserved as Private project
false label → row preserved as Unknown Project
custom label → row preserved with the custom text
skip unknown → row removed and next project backfilled
private row first → remaining bars renormalized after omission
Synthetic data is important here. A privacy test should not need a real API key or a fixture containing an actual private project name.
The test exists to prove the display policy, not to archive the sensitive input that motivated it.
Public metrics need a disclosure budget
When developers think about secret handling, we usually start with credentials.
Do not commit the API key. Use an environment variable. Put the CI value in a repository secret.
All correct. None of it decides what the generated artifact should reveal.
The SVG is public output. It needs its own disclosure budget.
For a private project row, that budget can be expressed field by field:
identity → hidden
existence → preserved or omitted
duration → preserved or omitted
rank → preserved or recomputed
bar scale → preserved within all rows or recomputed within public rows
This is the same reason a public resume projection should be an allowlist rather than a raw file read. The output boundary needs an explicit answer for each field and each derived signal.
Redaction is useful when I want to keep the shape of the truth while hiding an identity. Omission is useful when even the existence or magnitude of private work should not be public.
I support both in the generator because they serve different disclosure budgets.
The important part is not the environment-variable name. It is being able to explain, before publishing the SVG, which question the card still answers after privacy policy has transformed the data.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.