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 truePrivate project
  • false → preserve WakaTime's Unknown 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:

  1. whether the row remains in the dataset
  2. 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.