Pontius Pilate, Carved in Stone

In 1961, an Italian archaeological team excavating the Roman theater at Caesarea Maritima found a limestone block that had been reused in a staircase. It had once carried a public dedication. By the time it became building material, part of the inscription had been cut away.

Enough survived to identify the Roman official named on it: Pontius Pilate.

The stone dates to his administration, around A.D. 26 to 36. Caesarea was the Roman provincial capital, and the inscription refers to a Tiberieum, apparently a structure honoring the emperor Tiberius. Its exact form and purpose remain uncertain. The Israel Museum's description suggests it may have belonged to a temple.

The damaged text requires some reconstruction, and scholars have proposed different readings of the dedication. The identification of Pilate and his office is much firmer. Those two lines, with the missing letters restored, are translated:

Pontius Pilate
Prefect of Judea

From the Caesarea inscription, as translated in James J. C. Cox's discussion of the discovery.

That title matters. Matthew and Luke use a general word for governor. The inscription supplies the specific Roman title: prefect. It gives us an official designation from Pilate's own time, attached to his name in a public inscription.

It would be easy to turn this into a story about skeptics insisting Pilate never existed until someone dug up proof. But Pilate was already known from the Gospels and from writers outside the New Testament, including Philo, Josephus, and Tacitus. Those written sources were historical evidence before anyone found the stone. The discovery added direct archaeological corroboration; it did not rescue Pilate from being a fictional character.

For me, that is enough to make it worth paying attention to. Here is a surviving piece of the government under which the Gospel events took place. The official named in the accounts of Jesus' trial also appears in a Roman dedication concerned with an emperor and a building.

The inscription says nothing about Jesus or his trial. It cannot establish what was said in Pilate's headquarters, much less settle the Christian claims about the resurrection. What it does establish is narrower and concrete: Pilate held office in Judea, and a monument from his administration recorded his name and title.

I care about that distinction because the history matters to my faith. The Gospels place Jesus among identifiable people, under a particular government, in places that can be studied. An inscription like this gives us a point where their setting can be checked against evidence that survived outside the manuscripts.

There is something fitting about the way this one survived. A dedication important enough to carve in stone eventually became a useful block for a staircase. Centuries later, archaeologists read the name that was still there.

Syncing HubSpot Contacts in C#: Store the Contact ID, Not Just the Email

I recently built an integration for a client that keeps HubSpot contacts in sync with an internal user list. A console job runs every five minutes, compares the two sides, and sends changes through HubSpot's API. The production application runs on .NET Framework; the example here uses current .NET.

The tempting approach is to look up each user by email, create a contact if none is found, and update it otherwise. That works until the email changes. If the new address is not already associated with the existing contact, the lookup can miss it and the job can create a second record. The original contact still holds the earlier history.

The integration needs to remember which HubSpot contact belongs to the internal user. Email is useful for establishing that relationship, but it should not be the only thing keeping the relationship intact.

I store the HubSpot contact ID alongside the internal user's ID. Once that mapping exists, the normal sync updates the stored contact directly. A changed email address becomes another property to update on that record.

The initial linking step still needs care:

  1. If the user already has a stored HubSpot contact ID, use it.
  2. If the mapping is missing, check for an existing contact and confirm that it represents the intended user. Email can help with that lookup, but an API error is not evidence that the contact is absent.
  3. If a new contact is needed, create it and persist the returned ID before marking the user synchronized. Store IDs as strings, and scope the mapping to the HubSpot account if the integration serves more than one.

Creation and saving the local mapping are separate operations. HubSpot can accept a create request even if the response is lost or the database save fails. The next run needs to reconcile that uncertain outcome before creating again. Overlapping runs need coordination too, so two workers cannot independently establish different mappings for the same user.

HubSpot's search documentation says newly created or updated records may take a few moments to appear. An empty search result immediately after a write is not proof that the write failed. A custom unique property containing the internal user ID is another option for establishing identity; HubSpot supports it in its contact upsert API.

Once the mapping is established, the update is much simpler. This method assumes a reused HttpClient configured with https://api.hubapi.com/ as its BaseAddress and an appropriate bearer token. It also assumes the internal application owns the three properties being sent:

using System.Net.Http.Json;

static async Task<string> UpdateContactAsync(
    HttpClient http,
    string contactId,
    string email,
    string firstName,
    string lastName,
    CancellationToken cancellationToken = default)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(contactId);
    ArgumentException.ThrowIfNullOrWhiteSpace(email);

    using var response = await http.PatchAsJsonAsync(
        $"/crm/v3/objects/contacts/{Uri.EscapeDataString(contactId)}",
        new
        {
            properties = new
            {
                email,
                firstname = firstName,
                lastname = lastName
            }
        },
        cancellationToken);

    response.EnsureSuccessStatusCode();

    var updated = await response.Content
        .ReadFromJsonAsync<HubSpotContact>(
            cancellationToken: cancellationToken);

    if (string.IsNullOrWhiteSpace(updated?.Id))
        throw new InvalidOperationException("HubSpot returned no contact ID.");

    return updated.Id;
}

public sealed record HubSpotContact(string? Id);

The PATCH targets the contact ID, while the email travels in the request body. The method checks the HTTP status and returns the ID from the response. The caller should persist that ID and mark the update complete only after its local save succeeds. Sending only the properties this integration owns also avoids overwriting unrelated changes made by the marketing team.

When I tested the email change against the live portal, the update stayed on the existing contact and did not create another one.

A stored ID still needs a recovery path:

  • If the contact cannot be found, investigate the mapping and whether the record was deleted. Do not turn a failed update into an automatic create.
  • If the new email belongs to another contact, report the conflict for review. The sync should not choose another record to overwrite on its own.
  • Handle rate limits and temporary failures with bounded retries. Authentication and validation failures need different treatment. This small method surfaces unsuccessful responses; the surrounding job owns that policy.

For this integration, deactivating an internal user changes a status property in HubSpot. It does not delete the contact. The marketing team may still need that record and its history. HubSpot does support restorable and permanent deletions, but the retention and deletion rules belong in a separately defined workflow. An inactive user is not, by itself, an instruction to erase the CRM record.

Keeping the contact ID gives each routine update a known destination. It also leaves the exceptional cases visible, instead of letting the next email lookup quietly decide that an existing person is someone new.

Valerie’s Letter and the Freedom No One Can Take

V for Vendetta sits at the top of my movie list, and Valerie's letter is a large part of why. Evey is locked in a cell, her head shaved, enduring interrogation. Then she finds a letter written on toilet paper by a woman she has never met.

The letter comes from the graphic novel by Alan Moore and David Lloyd. The film adapts it, and Valerie's voice has stayed with me for years.

Valerie was an actress. She fell in love with Ruth, and for three years their home smelled of roses. Looking back from her cell, she remembers:

For three years I had roses, and apologised to no one.

That sentence gives us a life before it gives us a lesson. Work she loved. Someone to come home to. Flowers in the window. Then the regime takes Ruth while she is out buying food, and later it takes Valerie. She is imprisoned because she is a lesbian.

Writing to a stranger, Valerie tells the story of the person her captors want to erase. She remembers the people she loved and the years she was happy. At the end, she tells whoever finds the letter that she loves them, though she does not know their name and will never see their face.

V later tells Evey that Valerie was real. She wrote the letter before she died, and he passed it to Evey as it had been passed to him. Knowing that gives the words a history. They helped one prisoner endure, and now another person is reading them.

This is the passage I keep coming back to:

Our integrity sells for so little, but it is all we really have. It is the very last inch of us. And within that inch, we are free. We must never lose it or give it away. We must never let them take it from us.

Valerie refuses to let her captors' judgment become her own. They have declared her life unacceptable. She remembers it with love. Even as she faces death, she can offer kindness to someone beyond the walls of her cell. Her dignity was never theirs to grant.

As a Christian, I hear something familiar in her concern for what we are willing to surrender. Jesus asks what anyone gains by acquiring the whole world at the cost of their soul, and what could possibly buy it back (Mark 8:36-37). The language is different, but the question reaches me in much the same place. What am I treating as negotiable that I cannot afford to lose?

The regime's religious language makes its cruelty especially ugly. It invokes faith while treating people as disposable. Calling that a misuse of religion does not make the suffering smaller or excuse anyone who participates in it. My faith tells me that the person being humiliated bears the image of God. That conviction ought to govern how I treat people, especially when I disagree with them.

Valerie's letter also leaves me thinking about what it means for something to survive us. Her words reach V, then Evey. They give courage to people she will never know. The film gives that influence real weight, and it deserves it. Her death does not make her love or integrity meaningless.

Christian hope goes further than that. Jesus says that whoever loses their life for His sake and the gospel's will save it (Mark 8:35). He calls us to trust Him with our lives even when following Him costs us everything. The hope rests in Him, beyond our ability to preserve ourselves or ensure that someone remembers us.

Valerie hopes the world will turn and things will get better. I find that longing moving on its own. I also read it with the promise of Revelation 21:4-5 in mind: God ending death and mourning, wiping away tears, and making all things new. The hope I have in Christ is that death itself will be overcome, not simply that something I said or did will outlive me.

Watch the scene if you have never seen it. What stays with me is Valerie writing to someone she cannot know, with almost nothing left, and still finding something to give.

AI Makes a Great Apprentice and a Terrible Master

Recently, an AI review caught a technical claim I had written that was flat wrong. I was glad to be corrected. That same week, I threw out a confident AI answer because it included details I could not substantiate. Both experiences belong in any honest account of how useful these tools are.

That is why I think of AI as an apprentice. I can give it work, ask questions, and learn something from its answer. I still have to decide whether the answer is any good. Calling myself the master means very little if I accept whatever it hands me.

After thirty years of writing software, I still love the craft. Building things is part of what I was made for, and I gladly hand AI some of the mundane work: boilerplate, scaffolding, an initial pass over code I have stared at too long. Used carefully, it makes my work faster and better. I have no interest in giving that up just to prove I can do everything myself.

But reviewing the result is part of the job. For code, that means understanding the design, checking assumptions, and testing the behavior that matters. A successful build does not tell me whether I misunderstood a business rule or handled a failure badly. If I cannot explain a piece of code, I need to understand it before I ship it.

This matters when someone is learning, too. AI can produce code that looks far beyond a beginner's experience. It can also help that beginner work through an unfamiliar idea. The difference shows up in what happens next. Can they explain why the code works? Change it deliberately? Find the problem when it fails? Those are useful questions for an experienced developer as well. Having the answer in front of you can make it easy to overestimate how much you understand.

A 2025 study of 319 knowledge workers examined how people reported using critical thinking while working with generative AI. One finding was:

Specifically, higher confidence in GenAI is associated with less critical thinking, while higher self-confidence is associated with more critical thinking.

Hao-Ping Lee and colleagues, CHI 2025.

That was a survey of people's reported behavior, not proof that AI had damaged their ability to think. Still, it gets at the concern: trusting an answer can reduce the effort we put into examining it. An answer can sound settled long before we have done enough work to accept it.

I struggle with that discipline too. It is easy to ask one more question, get an answer that sounds reasonable, and move on. The checking takes time, especially when the answer agrees with what I already wanted to do. But the finished work goes out with my name on it. I am responsible for the mistakes I accept as well as the ones I write myself.

The same questions are useful beyond software. Before relying on an AI answer, ask:

  • Can I explain it in my own words?
  • How will I check whether it is right?
  • What could happen if it is wrong?
  • Am I prepared to take responsibility for using it?

Sometimes answering those questions takes longer than generating the work did. That is still my part of the job.

What The Count of Monte Cristo Taught Me About Providence

I keep Edmond's storm speech from the 2002 film The Count of Monte Cristo at the top of my favorites page. He delivers it as a toast at Albert's birthday celebration. It is a fine speech to hear when life is going well, and a harder one to live by when it isn't:

Life is a storm, my young friend. You will bask in the sunlight one moment, be shattered on the rocks the next. What makes you a man is what you do when that storm comes. You must look into that storm and shout as you did in Rome. Do your worst, for I will do mine! Then the fates will know you as we know you: as Albert Mondego, the man!

I understand the appeal. There are times when you need to stand your ground and do what the situation asks of you, whether you feel ready or not. Courage matters. I have carried those words around for years because I believe that.

But I also read the speech in light of the man giving it. Edmond has survived a terrible injustice, and he now has the money and influence to punish the people responsible. He can arrange events so carefully that he begins to act as though justice belongs to him. His resolve is impressive. His confidence in his right to control the outcome is more troubling.

The film has been working on that question since his imprisonment. When Edmond arrives at Chateau d'If, the words "God will give me justice" are already on the wall of his cell. In time, his faith gives way to a desire for revenge. When he tells Faria that he no longer believes in God, the priest replies, "That doesn't matter. He believes in you."

It is a memorable line, and I take it as the priest refusing to give up on Edmond. More directly, Faria warns him against vengeance. Edmond spends much of the remaining story ignoring that warning. Only after his revenge nearly costs him the people he loves does he return to the prison and promise that what he used for vengeance will now be used for good.

I read Dumas' novel in 2014, roughly twelve hundred pages in my edition. It takes a different route from the film and makes the danger of Edmond's role especially clear. His plans reach people who were never guilty of what happened to him. Faced with the death of an innocent child, he can no longer assume that his vengeance carries God's approval. The novel closes with the counsel to "wait and hope."

Those words ask something difficult of a man who has spent years arranging everyone else's future. Waiting means accepting that he cannot force every outcome. Hope means he has a reason to live beyond settling what is owed to him.

That is where the story turns my thoughts toward providence. By providence, I mean God's care and rule over a world I cannot control. I am responsible for what I do, but I do not have to make myself the final judge of everyone who has done wrong. Trusting God leaves room for courage, patience, and the possibility that my judgment needs correcting.

In Mark 4:35-41, the disciples are in a boat taking on water while Jesus sleeps in the stern. They wake Him, and He stills the wind and sea. Their question afterward is, "Who then is this, that even the wind and the sea obey him?" (Mark 4:41, ESV).

The question brings me back to who is in the boat. The disciples' courage is not what calms the water. They are with someone whose authority reaches beyond anything they can manage. That is a firmer basis for hope than my ability to shout defiantly into the weather.

I have had a few storms of my own these past several years. I still value the resolve in Edmond's toast. But I need to be able to trust God when I cannot see how things will turn out, and when doing my best does not put the outcome in my hands.

So the speech stays on my favorites page. I have simply learned to read it alongside the rest of Edmond's story: a man who has to learn what to do with his strength, and where his authority ends.

Negative Caching in C#: Cache Your Misses, Not Just Your Hits

I run a public API at MySafeInfo. While looking at database activity, I noticed how much work went into looking up API keys that did not exist: typos, old keys, and invalid values sent by bots. Valid keys were already cached. Repeated requests for a bad key kept going back to SQL Server.

The reason was easy to miss. My lookup followed this pattern:

public async Task<ApiKey?> GetKeyAsync(string key)
{
    if (_cache.TryGetValue(key, out ApiKey? cached))
        return cached;

    var apiKey = await _repository.GetKeyAsync(key);

    if (apiKey is not null)
        _cache.Set(key, apiKey, TimeSpan.FromMinutes(5));

    return apiKey;
}

The null check looks reasonable. Why store something you did not find? Because the absence is useful information too. Without it, the next request for the same nonexistent key repeats the database lookup.

Negative caching means keeping that result for a while. With Microsoft.Extensions.Caching.Memory, a cached value can be null. The important distinction is the Boolean returned by TryGetValue: true means an entry was found, even if its value is null. Checking only the returned value would lose that distinction.

Here is the revised method. The one-minute and five-minute lifetimes are examples, and this assumes the caller has already checked the key's expected format and bounded its length:

public async Task<ApiKey?> GetKeyAsync(string key)
{
    if (_cache.TryGetValue(key, out ApiKey? cached))
        return cached; // A cached null is a known miss.

    var apiKey = await _repository.GetKeyAsync(key);

    var options = new MemoryCacheEntryOptions()
        .SetAbsoluteExpiration(apiKey is null
            ? TimeSpan.FromMinutes(1)
            : TimeSpan.FromMinutes(5))
        .SetSize(1);

    _cache.Set(key, apiKey, options);

    return apiKey;
}

Once the miss is stored, later requests for that key can return without another database call until the entry expires or is evicted. The repository must reserve null for a completed lookup that found no record. A timeout or database failure should remain an error, rather than becoming a cached claim that the key does not exist.

I give misses a shorter lifetime because absence can change. If a key is created while a miss is cached, this method can keep returning null for the rest of that entry's lifetime. Absolute expiration bounds that delay; repeated requests do not extend it. If new keys must work immediately, their creation needs to coordinate with cache invalidation, including lookups already in flight.

Positive results need thought too. An API key might be revoked, expire, or have its permissions changed. Five minutes is not automatically an acceptable delay for those changes. Choose the lifetime and invalidation policy around the authorization rules, and continue checking expiry and other applicable restrictions when using a cached record.

The cache also needs a limit. For this integration I use a dedicated cache for API keys, which avoids imposing size requirements on unrelated code. Microsoft warns against adding a size limit to a shared cache unless every writer supplies an entry size.

using Microsoft.Extensions.Caching.Memory;

public sealed class ApiKeyCache : IDisposable
{
    public MemoryCache Cache { get; } = new(
        new MemoryCacheOptions
        {
            SizeLimit = 10_000
        });

    public void Dispose() => Cache.Dispose();
}

Register the wrapper as a singleton in Program.cs:

builder.Services.AddSingleton<ApiKeyCache>();

Inject ApiKeyCache into the lookup service and assign its Cache property to _cache. The dependency injection container owns the wrapper and disposes it at shutdown. ApiKey and _repository represent the application's existing model and data access code.

With every entry assigned a size of 1, the limit counts entries, including cached misses. It is not a byte limit, which is another reason to bound the length of incoming keys. An addition that would exceed the limit is rejected; the .NET implementation also schedules background compaction, which can evict existing entries. The method still returns the repository result even if the cache does not retain it.

There are two limits to what this small change buys you:

  • It helps with repeated keys. A different random key on every request still requires a lookup. That traffic can also fill the cache with misses and displace useful entries. Input validation and rate limiting still matter.
  • It does not combine simultaneous lookups. Several requests can all miss the cache before the first one stores its result. Each application instance also has its own memory cache. This reduces repeated work; it does not guarantee one database call per key per minute across the service.

The change that started this was just removing a null check. The useful question was why the database kept answering something the application had already learned.

Two Ways to Mask PII in SQL Server (and When Each One Fits)

Production data has a way of following an application into development and test. A database restore brings over the records needed to reproduce a bug, along with real names, email addresses, and phone numbers that nobody needed for the test. I worked through this with a client not long ago. Part of the work was sorting out what we meant by masking, because hiding a value in a query result and removing it from a database copy solve different problems.

Suppose a support application needs to display a customer's phone number with only the last four characters visible. SQL Server's Dynamic Data Masking can do that without changing the stored value:

ALTER TABLE dbo.Customer
ALTER COLUMN Email ADD MASKED WITH (FUNCTION = 'email()');

ALTER TABLE dbo.Customer
ALTER COLUMN Phone ADD MASKED WITH (FUNCTION = 'partial(0, "xxx-xxx-", 4)');

A reader without UNMASK permission sees the masked values. Accounts with that permission, including database owners, can see the originals. Test this using the account that actually reads the data; checking it as an administrator will give you the wrong impression.

This helps limit routine exposure, but someone allowed to run arbitrary queries may still infer the underlying values. Microsoft explicitly warns about that limitation. Keep access restricted to what the application or person needs.

And a backup still contains the real data. Restore it to development and the original names, emails, and phone numbers come with it, regardless of the masks.

For that copy, the job is static masking: replacing the sensitive values themselves. On the client project, we used Microsoft Purview to help identify columns containing sensitive data, then wrote T-SQL to scrub them. Classification helped us find the work; the update scripts did the replacement.

The replacement rules deserve some thought. Keeping the first letter of a name and filling the rest with x's still reveals its initial and length. Building every replacement email from that initial also creates duplicates. That can break a unique index before the masking pass finishes.

Here is a simplified example using synthetic values. It assumes CustomerId is a unique, non-null integer key, the text columns are long enough for the replacements, and Phone permits nulls. Run this only against the isolated copy being sanitized:

UPDATE dbo.Customer
SET FirstName = CASE WHEN FirstName IS NULL THEN NULL
                     ELSE 'Test' END,
    LastName  = CASE WHEN LastName IS NULL THEN NULL
                    ELSE 'Customer' + CONVERT(varchar(20), CustomerId) END,
    Email     = CASE WHEN Email IS NULL THEN NULL
                    ELSE 'customer.' + CONVERT(varchar(20), CustomerId)
                         + '@example.invalid' END,
    Phone     = NULL;

Existing null names and emails stay null. The generated email addresses are distinct for distinct customer IDs, and no part of the original name or address is used. The phone number is removed entirely. If the application requires phone numbers, supply synthetic test values that satisfy its rules instead.

The .invalid domain is reserved for deliberately invalid domain names. Even so, disable outbound email, SMS, and production integrations before the refresh starts. Replacing one email column does not catch a recipient stored in a notification queue or application configuration.

These replacements also change the data your tests see. A table full of short, predictable names will not exercise long names, apostrophes, or international characters. Add synthetic cases for those deliberately. Preserving bits of a customer's identity is a poor substitute for choosing the test data you actually need.

Before making the refreshed database available to developers, check more than whether the update completed:

  • Verify the replacement values against column lengths, unique indexes, and application validation. Where a value appears in related tables, use a consistent replacement so those relationships still work.
  • Look beyond the obvious customer columns. Free-text notes, saved request payloads, history tables, and queued messages can hold the same information in less convenient forms.
  • Check the resulting data for unexpected values, and review fields that classification did not flag. Updating four columns is not evidence that the whole database is sanitized.
  • Keep the source backup and any logs or other copies containing the originals protected. An UPDATE is not secure erasure, and retaining production IDs or other identifying details means you should not call the result anonymous.

Put the scripts and validation checks in source control and run them as part of every refresh. Keep the restored copy restricted until those checks pass. That way sanitizing the data is a condition of handing it over, rather than a task someone has to remember afterward.