What Billy Graham Taught Me About the Cross

I have listened to Billy Graham and read his books for a long time, especially over the past decade. His autobiography, Just As I Am, is one of my favorites. One story from it has stayed with me: the night a friend told him he had left the cross out of his sermon.

It happened in Dallas in 1953. Graham recalled feeling that his preaching that evening had lacked spiritual depth, even though people had responded to the invitation. Afterward, he took a walk with his friend John Bolten, a German-born businessman. Bolten told him what was missing.

“Billy,” he said, “you didn’t speak about the Cross.”

In the account from Just As I Am, Bolten pressed him to explain Christ's death to the people he was asking to believe. Graham spent a sleepless night thinking about that conversation. By morning, he had resolved to make the gospel clear in his preaching, with Christ's death for our sins and His resurrection at its center.

He also told the story in a 1959 address to ministers. There, he recalled praying:

“Oh, God, so help me, there will never be a sermon that I preach unless the Cross is central.”

I admire his willingness to receive that correction. He took it seriously enough to change his preaching and to include the failure in his own autobiography. Reading it years later, I am grateful that he did. It gives the rest of us something more useful than an account of a successful evening.

It is possible to say many true things about Christianity and still leave someone unclear about why Jesus died. We can talk about living well, finding purpose, or having hope. We can defend the reliability of Scripture and answer objections to belief. But the person listening still needs to hear what God has done for sinners, and why we need Him to do it.

That part of the story really hits home for me. I care about apologetics. I read the arguments, think about the objections, and want to give honest answers. Those questions deserve attention. I also want anyone who reads what I write about faith to understand what I am inviting them to believe. Christianity rests on Jesus Christ, who died for our sins and rose from the dead. He calls us to turn from sin and trust Him for forgiveness. We cannot earn it by becoming better at defending Christianity, or by becoming impressive Christians ourselves.

In 1 Corinthians 2:2 (ESV), Paul put his purpose plainly: “For I decided to know nothing among you except Jesus Christ and him crucified.” Later in the same letter, he reminded the Corinthians of Christ's death for their sins, His burial, and His resurrection (1 Corinthians 15:3-4). That was the good news Graham's friend was urging him to make clear.

For me, there is comfort in that as well as a responsibility. The message does not depend on my having Billy Graham's voice or his ability to speak to a crowd. I can explain what Christ has done, listen to someone's questions, and trust God with what I cannot accomplish in another person's heart.

I am thankful for Billy Graham and for the friend who spoke honestly to him that night. The reason the story stays with me is that I need the same Savior he preached. Whatever opportunity I have to speak about my faith, I want to make clear why my hope is in Christ.

Claude Is Losing My Work to ChatGPT

I left OpenAI's ChatGPT in March 2025 and moved to Anthropic's Claude full time. I did not expect to come back. Eighteen months later, I pay for both, and an increasing share of my work is going to ChatGPT.

That was not the outcome I was rooting for.

I had used ChatGPT since March 2024. Concerns about data control and privacy were part of why I left, along with unease about news surrounding OpenAI's leadership. I was more comfortable putting my money and time into Anthropic. For a long while, that felt like the right decision.

Since August 2026, I have been using ChatGPT heavily again. I have been comparing the two on work I actually need done: writing and editing, generating and reviewing code, improving processes, and analyzing data. I am a full-stack developer with more than thirty years in software. These tools have to earn their place in that work.

The redesign of My Family Devotion made the difference particularly clear. I wanted to improve the site's user interface, user experience, and SEO. In my comparison, the ChatGPT workflow produced:

  • A more polished design that adapted better to different screen sizes and worked better on mobile.
  • Cleaner HTML and code, with far fewer rounds of correction to reach a higher-quality deliverable.
  • Better graphics, including higher-resolution assets, along with better logo, icon, and typography choices.
  • Faster completion and more work accomplished within the usage allowance.

Astra also spotted that the individual devotions were missing unique page titles and descriptions. That gave me a specific SEO issue to address alongside the visual redesign. Any resulting change in search traffic or rankings would need to be measured separately.

That project helps explain why more of my work has been moving back. I still use Claude, and the preference varies by task. But on this redesign, ChatGPT gave me a better result with less back-and-forth. Those are differences that matter when I am responsible for finishing and maintaining the application.

My subscriptions are Claude Max 5x and ChatGPT Pro 5x. As of September 2026, both have a published US web subscription price of $100 a month. The matching 5x labels refer to each company's own baseline; they do not promise equal capacity. What matters to me is how much useful work each subscription buys.

A model can produce an excellent first answer and still be expensive to work with. The answer might need revision, the code needs testing, and the analysis needs checking. I need enough usable capacity to get through all of that. Access to an impressive model is worth less if I cannot afford to finish the work with it.

Token use is a large part of this. Tokens are the units used to represent the content models process and generate. The short question I type is only part of what an AI system may process during a task. Conversation history, files, instructions, tool results, and reasoning can all contribute to usage. Three details matter particularly in longer sessions:

  • Context management determines what information is carried into the next step. Loading a whole repository when a few files would do can consume capacity without helping the answer.
  • Prompt caching reuses processing of unchanged input. Both OpenAI and Anthropic document API caching that can reduce repeated-input costs. It is not the same as retrieving an old answer, and its availability does not guarantee that a particular request benefits from it.
  • Compaction reduces the context carried forward so a long task can continue. OpenAI describes it as balancing quality, cost, and latency. From my side of the conversation, it also needs to preserve the decisions and constraints that matter. If I have to reconstruct them afterward, that adds work.

There is an important billing distinction here. API charges and a subscription's usage allowance are different things. I cannot take an advertised API cache discount and assume it gives me the same increase in subscription capacity. Anthropic's usage documentation explains that long conversations, model choice, and features affect the allowance. OpenAI's documentation for Codex and ChatGPT Work also describes usage that varies with task complexity, context, tools, and caching.

Both companies have optimization mechanisms. My side-by-side results do not reveal which internal mechanism explains the difference I experience. They do tell me where I currently get more useful work for my money.

I would consider a lower-ranked model if it gave me substantially more room to work and still met the standard the task required. Extra attempts are useful only if they lead to a result I can verify; reviewing ten bad patches is expensive too. Sometimes paying more for a model that gets a difficult job right sooner is the economical choice. The comparison has to include the whole task, including my time.

Cost has not displaced the concerns that made me leave ChatGPT. I still want clear answers about what gets retained, what can be used for training, and what control I have over either. I need to understand the terms of the particular product and account I am using. A company's reputation cannot answer those questions for me.

The same goes for safety features. When an agent can access files, run commands, or connect to another service, I want meaningful control over those permissions and a clear record of what it did. Transparency also means explaining limits and changes well enough that I can plan around them.

Reliability belongs in the cost calculation as well. An outage during the time I have set aside to work, an interrupted task, or a limit I cannot reasonably anticipate can make a capable tool difficult to depend on. I want to evaluate that alongside the quality of its answers.

Lately, my experience as an Anthropic customer has left me feeling that my business matters less than I thought it did. That is a judgment about the experience I am paying for, not a claim to know anyone's motives. ChatGPT, to my surprise, has been earning more of my work. I am willing to acknowledge that even though I once thought I was finished with it.

I am still paying both companies. Anthropic can earn that work back, and OpenAI can lose it. I have spent too long in this industry to keep choosing a tool because it is the one I defended last year.

I Found a Security Bug in My Own Ten-Year-Old Tool

I was redesigning the interface and user experience of My New Password when I noticed what I had used to generate its passwords: Math.random(). The tool was about ten years old, and I used it frequently myself. I had put the wrong source of randomness in it and continued using it for years.

There was no known compromise or password leak. The passwords were generated locally in the browser, without being sent to a server. The problem was in how I chose the characters that made up each password.

If you want a random character, you can multiply Math.random() by the length of your alphabet, round down, and pick that slot. The passwords look random, and the code runs without complaint. For picking a random color or adding variation to an animation, that is fine.

Passwords need a source designed to resist prediction. Math.random() does not provide cryptographically secure randomness. That does not establish that someone could recover a particular password from my site, but it was enough to tell me I had chosen the wrong function for the job.

The replacement was crypto.getRandomValues(), part of the Web Crypto API. It fills an integer array with cryptographically strong random values. For example:

const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
const value = buf[0]; // An integer from 0 through 2 ** 32 - 1.

That gives me a random integer, but I still need an index into my alphabet. Using value % alphabet.length can introduce a small bias if the alphabet size does not divide evenly into the number of possible values. Some character positions get one more value mapped to them than others.

The way to avoid that bias is to accept only a range whose size is an exact multiple of the alphabet size. If a draw falls outside that range, draw again. Here is a helper that does that and returns an integer from zero up to, but excluding, max:

function secureRandomInt(max) {
    const range = 2 ** 32;

    if (!Number.isInteger(max) || max < 1 || max > range) {
        throw new RangeError("max must be an integer from 1 through 2 ** 32.");
    }

    const limit = Math.floor(range / max) * max;
    const buf = new Uint32Array(1);
    let n;

    do {
        crypto.getRandomValues(buf);
        n = buf[0];
    } while (n >= limit);

    return n % max;
}

A 32-bit unsigned integer has 2 ** 32 possible values, including zero. That is the range size used in the calculation. For a typical password alphabet, almost every draw passes on the first try. Each character position then has the same chance of being selected.

I replaced the uses of Math.random() in the password generation with cryptographic randomness. The change was small, and the passwords still looked the same. Looking at the output would never have told me whether I had used the right function.

Why Your WHERE Clause Ignores a Perfectly Good Index

You add an index on a column in the WHERE clause, run the query again, and still get a scan. Before adding another index, I look at how the query uses the column. A function, a mismatched parameter type, or a leading wildcard can prevent SQL Server from using the index to narrow the search.

The term for a condition that supports that kind of access is SARGable, short for search argument-able. It means SQL Server can use the condition to seek into a suitable index. It does not guarantee that the optimizer will choose a seek.

Dates are a useful example. Suppose OrderDate is the first key column in an ordinary rowstore index:

SELECT OrderId, CustomerId, Total
FROM dbo.Orders
WHERE YEAR(OrderDate) = 2025;

The index is ordered by OrderDate, while the filter asks for YEAR(OrderDate). SQL Server generally evaluates that expression against the rows it reads instead of deriving a date range to seek. It might still scan the index, so the index is not necessarily unused. The problem is how much data it has to read.

For an OrderDate stored as date, datetime, or datetime2, express the same condition as a range:

SELECT OrderId, CustomerId, Total
FROM dbo.Orders
WHERE OrderDate >= '20250101'
  AND OrderDate <  '20260101';

Now SQL Server has a starting point and an ending point in the index. The exclusive upper boundary includes all of December 31, even when the column stores a time of day. This makes a range seek possible without having to calculate the year for each candidate row.

The less obvious case is a parameter mismatch. Suppose ProductCode is an indexed varchar(20) column:

SELECT ProductId, Name
FROM dbo.Product
WHERE ProductCode = @code;

The query looks straightforward. But when SqlClient infers a parameter type from a .NET string, it uses nvarchar. SQL Server then has two types to reconcile, and nvarchar has higher precedence than varchar. The comparison involves converting the column's value to nvarchar.

Whether that prevents a seek depends partly on the column's collation, its rules for comparing and sorting text. With SQL_Latin1_General_CP1_CI_AS, this mismatch can turn a seek into a scan. Windows collations can preserve the ability to seek. Solomon Rutzky's tests of both cases show why the same mismatch does not always produce the same plan.

I would still match the parameter to the column. In ADO.NET, explicitly set SqlDbType.VarChar and the appropriate size. In Dapper, DbString lets you specify that. For this varchar(20) column, with a product code already validated to fit:

var rows = conn.Query<Product>(
    "SELECT ProductId, Name FROM dbo.Product WHERE ProductCode = @code",
    new
    {
        code = new DbString
        {
            Value = code,
            IsAnsi = true,
            IsFixedLength = false,
            Length = 20
        }
    });

Use the type and length your column actually declares. An nvarchar column should receive an nvarchar parameter. The point is to remove an avoidable mismatch, then check whether it was affecting the plan.

Wildcards have a similar practical consequence. LIKE 'John%' supplies a fixed prefix that can support a range seek on an index keyed by the name. LIKE '%son' supplies no such prefix. That condition alone cannot narrow an ordinary index on the name to a useful starting range. SQL Server may scan the index or table, or use another condition to reduce the rows first. Adding the leading wildcard changes the work involved in the search.

Keeping the indexed column unmodified in a comparison is a useful starting rule. If filtering on a transformed value is a recurring requirement, an indexed computed column may be appropriate, provided it meets SQL Server's requirements for the expression and index. That gives the optimizer an index on the value you actually search.

Before calling any of these changes a fix, I check the actual execution plan:

  • Which conditions appear under Seek Predicates, and which are evaluated as residual filters after rows have been located?
  • Is there a CONVERT_IMPLICIT expression on the indexed column?
  • How many rows did the access operator read compared with how many it returned?

A seek can still read a large range and discard most of it. A scan can be sensible for a small table or a query returning a large share of its rows, especially when a seek would require many additional lookups to fetch the selected columns.

I compare logical reads with SET STATISTICS IO ON and check execution time using representative parameter values. Changing a scan icon to a seek icon is only useful if the query returns the right results with less work.

We Call Out to Dry Bones

There are songs that make you tap your foot, and there are songs that take you to your knees to worship. Lauren Daigle's "Dry Bones" is the second kind for me. It is one of the songs I come back to again and again when I want to worship, because it does not just describe God's power, it makes me feel small in front of it, in the way you are supposed to feel small in front of something almighty.

The song is built on one of the strangest and most vivid scenes in the Old Testament. The prophet Ezekiel is set down by God in the middle of a valley, and the valley is full of bones. Not fresh graves. Bones, scattered and picked clean and, in Ezekiel's own word, very dry. This is death that has been death for a long time. And then God asks him a question that has no reasonable answer:

And he said to me, "Son of man, can these bones live?" And I answered, "O Lord GOD, you know." (Ezekiel 37:3, ESV)

I love Ezekiel's answer, because it is so honest. He does not say yes, and he does not say no. Looking out over a field of the long dead, "you know" is the only thing a sane person could say. It is not a failure of faith. It is faith admitting that the outcome is entirely in God's hands, which happens to be exactly where it belongs.

Then God tells him to preach to the bones. To speak life over the most obviously dead thing imaginable. And as Ezekiel obeys, there is a rattling across the valley, and bone comes to bone, and sinew and flesh and skin, and finally breath, until what was a graveyard is standing up alive. The vision is about Israel, a people who had given up, who said their hope was gone and they were cut off. God's answer to that despair is not a pep talk. It is a resurrection:

And I will put my Spirit within you, and you shall live. (Ezekiel 37:14, ESV)

This is the passage Daigle's song lives inside, and you can hear the whole arc of it in the music, the dryness, the question, and then the breath. I will not quote the lyrics here, but I will do something better and link you to it. Turn it on with the volume up, ideally when you have a moment to actually listen rather than have it on in the background.

Lauren Daigle, "Dry Bones"

Here is why this song resonates with me. There was a stretch of my life, years ago, that was as dark as anything I have known. I was a Christian by then, at least on paper, but I was lost, without purpose, and worn down to nothing. I was about as dry as those bones. I could not preach myself back to life any more than a skeleton in that valley could, and I had finally run out of trying.

What changed things for me was not a formula, not a strategy, and not a list of rules I could follow to earn it. Rather, it was surrender. One night, at my lowest point, I got down on my knees and gave God the whole thing, my life, my future, all of it, because I had nothing left to hold onto and no strength left to hold on with. I did not fix myself. I quit trying to, and asked Him to do what only He could. The next morning, my Bible was open beside me to the following passage:

I waited patiently for the LORD; he inclined to me and heard my cry. He drew me up from the pit of destruction, out of the miry bog, and set my feet upon a rock, making my steps secure. He put a new song in my mouth, a song of praise to our God. (Psalm 40:1-3, ESV)

He drew me up from the pit. He set my feet on a rock. He put a new song in my mouth. That is not poetry to me, it is autobiography, because I lived it. I was the dead thing made alive, and I did nothing to earn it except stop pretending I could save myself. That is the part Daigle's song draws me into worship with every time.

That is what moves me to worship. Not God as a comforting idea, but God as the one who stands over a valley of the dead and asks whether they can live, already knowing that at His word they will stand up by the thousands. He did it for a field of bones in Ezekiel's vision. He did it for me on a night I had given up. When I remember that this is who God is, the only reasonable posture left is the one the song puts me in. Small, grateful, and alive.

Deleting Millions of Rows in SQL Server, One Batch at a Time

I run scheduled jobs on several systems that remove data past its retention window. Depending on the system, that might mean anything older than ninety days or a year. On a busy logging or event table, one cleanup can involve millions of rows.

The SQL is simple:

DELETE FROM dbo.EventLog
WHERE CreatedUtc < @Cutoff;

The amount of work behind it is not. SQL Server has to find the rows, delete them, and maintain the affected indexes. Triggers and foreign-key relationships can add more work. A large delete can block other requests and put substantial pressure on the transaction log. Canceling it can also leave SQL Server with a lot to roll back.

For these jobs, I prefer smaller transactions. Each batch commits a limited amount of work, and the job can pause between batches. That gives me something I can tune while watching how the application responds.

First, the job needs an efficient way to find expired rows. An index beginning with CreatedUtc is worth evaluating if an equivalent index does not already exist:

CREATE NONCLUSTERED INDEX IX_EventLog_CreatedUtc
    ON dbo.EventLog (CreatedUtc);

Without a useful access path, each batch may spend more time finding its next rows than deleting them. The index can help with that search, though SQL Server also has to maintain it during deletion. Check the actual plan; a batch size does not fix an expensive scan.

Here is a starting pattern for a dedicated cleanup connection. This example assumes CreatedUtc is a datetime2 column containing UTC values:

SET NOCOUNT ON;

IF @@TRANCOUNT <> 0
BEGIN
    THROW 50000, 'Run this cleanup without an existing transaction.', 1;
END;

SET IMPLICIT_TRANSACTIONS OFF;
SET XACT_ABORT ON;

DECLARE @BatchSize INT = 1000;
DECLARE @Cutoff DATETIME2(7) = DATEADD(DAY, -90, SYSUTCDATETIME());
DECLARE @Rows INT;

WHILE 1 = 1
BEGIN
    DELETE TOP (@BatchSize)
    FROM dbo.EventLog
    WHERE CreatedUtc < @Cutoff;

    SET @Rows = @@ROWCOUNT;

    IF @Rows = 0
        BREAK;

    WAITFOR DELAY '00:00:01';
END;

The cutoff is calculated once, so the retention boundary stays fixed during the run. The loop captures @@ROWCOUNT immediately after each delete and stops when a batch removes no rows. DELETE TOP does not promise an order; for this cleanup, any rows older than the cutoff are eligible.

The transaction setup matters. With no existing transaction and implicit transactions disabled, each DELETE commits independently. Do not wrap the loop in an outer transaction. That would keep the work uncommitted across batches, including during the pauses. If a later batch fails, the earlier committed batches remain done.

A thousand rows and a one-second pause are starting values to test. Smaller batches usually shorten each transaction; a pause reduces how aggressively the cleanup competes for resources. Neither setting guarantees that another request will avoid waiting.

The often-quoted 5,000-lock escalation threshold is about locks, not deleted rows. Index access and related operations can acquire additional locks, and lock memory pressure also matters. Deleting 4,500 rows is not a reliable way to stay below the threshold. Blocking can happen without escalation, too.

The transaction log needs separate attention. DELETE is fully logged, including when it runs in small batches. Batching limits the size of each transaction; it does not make the deletions minimally logged.

Whether inactive log space becomes reusable depends on the recovery model and log truncation:

  • Under SIMPLE recovery, a checkpoint can make inactive log space reusable. A commit does not automatically cause a checkpoint after every batch.
  • Under FULL or BULK_LOGGED recovery, regular log backups are normally required for reuse. Committing batches does not replace those backups.

Other activity can delay reuse, including an active transaction or an availability replica that has fallen behind. When the log keeps growing, check the reported reason:

SELECT name, recovery_model_desc, log_reuse_wait_desc
FROM sys.databases
WHERE database_id = DB_ID();

Reusable space stays inside the log file; truncation does not shrink the physical file. I plan log capacity and backup frequency around the cleanup volume, rather than expecting batching to keep the file at a fixed size.

Before leaving a job unattended, I would measure batch duration, blocking, log use, and application response times under realistic load. I would also give it a runtime limit so a growing backlog cannot push an overnight cleanup into the busiest part of the day.

The pause may make the job take longer overall, but elapsed time alone does not tell me whether it is behaving well. I want the expired rows removed at a pace the system can support while people continue using it.

Stop Calculating Active Directory Password Expiry and Ask the Domain Controller

A single sign-on application I work on used to calculate Active Directory password expiry from pwdLastSet and the domain's default maximum password age. That worked until the organization introduced fine-grained password policies. Different accounts could now follow different expiry rules within the same domain.

Some users began receiving warnings for dates that did not apply to them. The application was doing the arithmetic for the wrong policy.

Active Directory already exposes the answer through msDS-UserPasswordExpiryTimeComputed. It is a constructed attribute: the domain controller calculates it when requested, using the account's effective password policy and relevant account settings.

I compared the old calculation with the computed attribute across 4,230 accounts in three domains. The results agreed for 4,041 accounts. Among the differences, two findings stood out:

  • Of the 121 accounts governed by fine-grained policies, 45 had been assigned an expiry date that was wrong by 305 days.
  • Another 123 accounts differed by exactly one hour across daylight saving transitions. The old code used local calendar arithmetic, while the domain controller worked from UTC file-time data.

That comparison was more useful than checking a couple of accounts that happened to follow the default policy. It showed both a policy problem and a time-handling problem.

Reading the attribute is straightforward, but interpreting it takes a little care. Microsoft's documented rules include special values:

  • Int64.MaxValue reports no finite expiry. This can come from a non-expiring password policy or account settings such as password-never-expires or smart-card-required. It does not identify the password-never-expires checkbox alone.
  • 0 means pwdLastSet is zero or absent, after the earlier account-setting checks. For an ordinary user account, zero is commonly associated with a required password change at the next logon.
  • A finite expiry value is a Windows file time. Convert it with DateTime.FromFileTimeUtc, after checking the special values.

I would keep those states separate from the date itself. Using DateTime.MinValue and DateTime.MaxValue as substitutes makes it too easy for later code to treat a status as an actual date.

This read-only PowerShell example uses DirectorySearcher and explicitly requests the attribute. Replace the sample domain controller, search base, and account name. It runs on Windows under the current identity and assumes LDAPS is configured with a trusted certificate:

Add-Type -AssemblyName System.DirectoryServices

$attribute = 'msDS-UserPasswordExpiryTimeComputed'
$auth = [System.DirectoryServices.AuthenticationTypes]::Secure -bor
        [System.DirectoryServices.AuthenticationTypes]::SecureSocketsLayer

$root = [System.DirectoryServices.DirectoryEntry]::new(
    'LDAP://dc.example.com:636/DC=example,DC=com',
    $null, $null, $auth)
$searcher = $null

try {
    $searcher = [System.DirectoryServices.DirectorySearcher]::new($root)
    $searcher.Filter = '(&(objectCategory=person)(objectClass=user)(sAMAccountName=jdoe))'
    [void]$searcher.PropertiesToLoad.Add($attribute)

    $result = $searcher.FindOne()
    if ($null -eq $result) {
        throw 'Account not found in the search base.'
    }

    $values = $result.Properties[$attribute.ToLowerInvariant()]
    if ($null -eq $values -or $values.Count -ne 1) {
        throw 'The domain controller did not return an expiry value.'
    }

    $raw = [Int64]$values[0]
    $expiresUtc = $null

    if ($raw -eq [Int64]::MaxValue) {
        $state = 'NoFiniteExpiry'
    }
    elseif ($raw -eq 0) {
        $state = 'NoLastSetTime'
    }
    else {
        $expiresUtc = [DateTime]::FromFileTimeUtc($raw)
        $state = 'ExpiryDate'
    }

    [pscustomobject]@{
        State = $state
        ExpiresUtc = $expiresUtc
    }
}
finally {
    if ($null -ne $searcher) { $searcher.Dispose() }
    $root.Dispose()
}

The search targets a domain controller in the account's domain. The filter uses a fixed sample account name; application code that builds a filter from input must escape LDAP filter values. The script reports a missing account or attribute as an error, before either can accidentally become a numeric zero.

Keep the expiry in UTC when comparing it with the current time. FromFileTimeUtc returns a UTC DateTime; FromFileTime returns local time. Convert for display when needed. Neither method can turn Int64.MaxValue into a valid DateTime, which is why the special-value checks come first.

There is also a difference between the .NET access paths. DirectorySearcher returns this value as an Int64. Reading through DirectoryEntry can expose an ADSI large-integer COM object with HighPart and LowPart instead. If you use that path, explicitly load the attribute with RefreshCache and handle the returned representation. Checking Properties.Contains alone does not ensure that the constructed attribute was requested.

I would also remove the silent fallback to the domain's default maximum age. That is the calculation that produced the wrong dates in the first place. If compatibility requires retaining it temporarily, report the result as an estimate and make the fallback visible in logs or metrics. A missing directory value should not become a confident expiry date or a claim that the password never expires.

The application still needs to decide when and how to warn someone. I would rather maintain that logic than a second implementation of Active Directory's password-policy rules.