Skip to content

feat(intermodal) INT-12 freeTimeExpiry chargeableDays demurrageClock #193 - #281

Merged
craig-o-curtis merged 17 commits into
mainfrom
feature/193-int-12-freetimeexpiry-chargeabledays-demurrageclock
Sep 24, 2026
Merged

craig-o-curtis merged 17 commits into
mainfrom
feature/193-int-12-freetimeexpiry-chargeabledays-demurrageclock

Conversation

@craig-o-curtis

@craig-o-curtis craig-o-curtis commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Title: feat(intermodal): free time, demurrage and detention (INT-12, #193)


Summary

Adds the intermodal/ namespace: freeTimeExpiry, chargeableDays and demurrageClock. They lay out a container's free time and count the charged days with their dates. They also pick which two events a demurrage, detention, storage or combined clock runs between, on the import or export leg. No world standard fixes how these days are counted, so every counting term is a parameter with no default, because each is worth days of charges. The story ships a guide, scenarios, a mistakes page and a teaching widget, the Free Time Ledger. The branch also carries separate Dox chat speed, reliability and formatting work, described in its own section.


What Changed

1. The intermodal namespace (packages/gmt)

Three functions, one shared day walker, and their types.

  • freeTimeExpiry(clockStart, freeDays, { basis, timeZone, firstDay, calendar? }) returns the first and last free day as local dates, and expiresAt, the instant free time ends. firstDay (eventDay or nextDay) has no default, because the two conventions differ by a full day of charges.
  • chargeableDays(clockStart, clockEnd, freeDays, options) returns the free days used, the chargeable days, the charged dates and the days in each tiers band. chargeBasis counts charged days separately from basis, which counts free days. Outside the US both are mostly calendar days. The usual US shape is working-day free time followed by calendar-day charges, and California law charges working days only. Expiry is half-open: a gate-out exactly at expiresAt is free, and one nanosecond later is a charged day.
  • demurrageClock(events, scope, { direction, startEvent? }) selects the events for demurrage, detention, storage or combined, per leg:
    • Import: discharge (or availability) to gate-out, and gate-out to empty return.
    • Export: full gate-in to loaded, and empty release to full gate-in.
    • Event names map to DCSA Track & Trace equipment events.
  • internal/freeTimeLedger.ts walks the zone's real local days, using the same boundaries as floorToZone:
    • A 23- or 25-hour day is one day.
    • A deleted date is never counted.
    • The walk never steps back into an earlier date.
    • An event day that began before Temporal's first instant is anchored at the clock start.
    • The walk stops after 10,000 days.
  • New types: FreeTimeOptions, FreeTimeBasis and FreeTimeFirstDay in types/, with result and argument types beside each function. intermodal and intermodal/calculate join the package.json exports.

2. Tests

  • Five new test files. They cover the spec's rows, every basis × firstDay and basis × chargeBasis pair, and both legs and all four scopes of the clock.
  • Half-open rows to the nanosecond.
  • Probe-zone rows: Santiago, Apia, Goose Bay, New York, Havana, Chatham, Manila 1844, Lord Howe and Casey.
  • Range-limit rows at both ends, east and west of UTC.
  • The battle-test zone sweep, including an agreement check with dwellTime.
  • The 10,000-day cap.
  • Hostile input: arrays with holes, unsafe integers, and an explicit null for an option.
  • The RFC 9557 annotation matrix, across all three functions.

3. Docs site (apps/dox)

  • A guide, guides/industries/intermodal-free-time-and-demurrage.mdx.
  • Three scenarios: free-time-start-day, demurrage-across-a-weekend and detention-is-not-demurrage.
  • mistakes/intermodal.mdx, including assuming that working-day free time means working-day charges.
  • Index entries. Every result shown was run against the built package.
  • The Free Time Ledger (/tools/free-time-ledger/). It draws a container's clock on the terminal's local-day grid and colours each day as the tariff reads it. Its controls set the start day, the free-day basis and the charged-day basis. It is also the showFreeTimeLedger chat tool, and it reuses the Dwell Ledger's grid helpers.
  • Part of the widget's charge-basis wiring (free-time-ledger-mount.ts) landed in 55b0018 (section 6), not in an INT-12 commit.

4. World standards, and what is US-only

  • context/domination/research/int-12-demurrage-conventions.md records every source, tagged by jurisdiction:
    • US rules: 46 CFR 541 and 545, the 2024 FMC final rule, and California § 22928.
    • The industry standard: DCSA.
    • Carrier tariffs: Maersk, MSC, CMA CGM, Hapag-Lloyd and ACL.
    • National rules.
  • 46 CFR 541.6 is labelled everywhere as the US invoice rule, not a world standard. No world regulation of these invoices exists. Only the available event and the invoice-field wording rest on US law alone.
  • Updated to match: the INT-12 spec, painpoints.md, both READMEs, the gmt-timezone skill and its artifacts, and the changeset. The tracker marks INT-12 Done, which unblocks INT-58.

5. Review fixes

  • Walker stepped back a day. A clock starting in Goose Bay's one-minute first pass of 7 November freed or charged the re-entered 6th. The walker now only moves forward to later dates.
  • Holes and unsafe integers. tiers, freeDays and the event list now reject holes and unsafe integers. A holed tier array used to produce NaN bands.
  • Minimum instant. A clock starting at the minimum instant west of UTC returned null. It now gets the right answer.
  • Wording. US tariff practice is no longer described as universal. The storage citation is now DCSA. "None has a default" was false and is corrected.

6. Other work on this branch (not INT-12)

  • 1a2a1fa, 55b0018: the Dox chat times every stage, fails over between brains without backoff, and builds its search index once per corpus. A widget whose library fails to load now shows as unavailable, instead of mounting controls that do nothing.
  • f462d44: a busy model (503 or 529) fails over without being marked as spent. The stream opens early and shows progress. Starter pills open a seeded widget. Widget mounts are lazy, so none is in the first download. Vite pre-scans src/lib so dev reloads do not break widget imports.
  • 5b9d9c3: the why-gmt API surface chart splits into core-namespace and industry-layer tabs, derived by stats.mjs.
  • b6255bb, 2cd5bae: formatter line wrapping only. No code changes: each file matches its previous version once whitespace is ignored.

Why

  • Free time is set by carrier, lane and contract, never by port, so every term has to be a parameter.
  • A silent default for the start day or either counting basis produces a wrong invoice by days.
  • Free days and charged days are counted differently in practice. The first draft let working-day free time also suspend charging, which under-bills the usual US shape.
  • Maersk, CMA CGM and MSC publish export clocks and a combined clock, and the first draft covered only the import leg.
  • The library should follow world practice and label the one US rule as US-only.

Validation

  • pnpm run validate exits 0: deps, test markers, build, stats, temporal-compat, upstream, api-surface, lint, typecheck and every test.
    • gmt: 36,850 tests in 673 files
    • dox: 4,528 tests in 80 files
    • gmt-oxlint: 97 tests
    • api-surface: 2,865 examples and 631 documented results checked, 0 failing
  • pnpm exec intent validate packages/gmt/skills: all 9 skill files pass.
  • pnpm changeset:status: @northguild/gmt minor.
  • astro build emits every new page.
  • gmt-reviewer, pass 1. Two blocking findings, both fixed and pinned with tests: the walker stepping back into Goose Bay's 6 November, and holed tier arrays giving NaN bands.
  • gmt-reviewer, pass 2, after chargeBasis, direction and the export clocks were added. One blocking finding, fixed and pinned: null at the minimum instant west of UTC. The should-fix items are fixed too: the US-shape wording, the US invoice rule label, the DCSA storage citation, and the zero-free-day wording.
  • Not run: html-diff and visual:diff for /tools/free-time-ledger/. They need a main baseline.

Risk / Impact

Medium. The three functions are new API, so no shipped behaviour changes. The day walker is the part with edge cases, and the probe-zone, range-limit and dwellTime agreement tests guard it. The Dox chat and widget-loading changes in section 6 touch every widget page and the chat Worker, and should be reviewed on their own.


Release

  • .changeset/patient-gates-free-time.md: minor, for the new intermodal/ namespace.
  • .changeset/quiet-harbours-dwell.md (TRAN-8) is still pending from the previous PR.
  • Merging publishes nothing. The tracker's release cut comes after INT-58, so this ships with TRAN-8 and INT-58 in the next release.

Checklist

  • Each "What Changed" section has a corresponding entry
  • Type-check passed
  • No secrets committed
  • Changeset present with the correct bump
  • gmt-reviewer run on the final API; all findings fixed

Closes #193

…INT-12, #193)

Adds the `intermodal/` namespace, the first consumer of `dwellTime`'s local-day
count. Free time is contractual, not per-port, so every term is a parameter and
none has a default.

- `freeTimeExpiry(clockStart, freeDays, { basis, timeZone, firstDay, calendar? })`
  lays free time out in the terminal's local days and returns the half-open
  expiry: the first instant of the day after the last free day. `firstDay`
  (`eventDay` | `nextDay`) has no default because the two differ by a day of
  charges; the working basis needs a `BusinessCalendar` and returns `null`
  without one.
- `chargeableDays(clockStart, clockEnd, freeDays, options)` counts the days on
  or after expiry that the half-open dwell touched, lists them as `chargedDates`
  (46 CFR 541.6) and splits them into `tiers` bands. A gate-out exactly at
  `expiresAt` is free; one nanosecond later is a charged day. `freeDays: 0` is a
  tariff with no free time.
- `demurrageClock(events, scope, { startEvent? })` selects discharge (or
  availability) to gate-out for demurrage and storage, and gate-out to empty
  return for detention. A required event missing or present twice returns
  `null`; the result is an `Interval` in the caller's own strings.
- One walker, `internal/freeTimeLedger.ts`, serves both bases over the zone's
  real local-day buckets from `zonedBucket.ts`. A deleted date (Apia) is never
  a free or charged day, a re-entered date (Goose Bay) is folded into its day,
  and the walk gives up after 10,000 days. On the calendar basis
  `freeDaysUsed + chargeableDays` equals `dwellTime(...).calendarDays`, and the
  tests assert it across the battle-test zones.

Subpaths `@northguild/gmt/intermodal` and `intermodal/calculate` join the
exports, with the `FreeTime`, `FreeTimeCharges`, `TierBand`, `ClockEvent` and
`FreeTimeOptions` types. Minor changeset.
…ence (INT-12, #193)

- `packages/gmt/README.md` gains the fourteenth subpath and a Free time and
  demurrage section whose every result was run against dist; the root README
  lists `intermodal` among the subpaths.
- The `gmt-timezone` skill routes free-time questions: description clause, the
  intermodal source, a new core rule and the API index line. `domain_map.yaml`
  and `skill_tree.yaml` carry the three functions and their tasks.
- The dox module registry loads `intermodal/calculate`; the reference corpus,
  route manifest and published test stats are regenerated.
- New `/tools/free-time-ledger/` page and `showFreeTimeLedger` chat tool with
  a starter pill. It draws a container's clock on the terminal's real
  local-day grid, reusing the Dwell Ledger's grid helpers, and colours each
  day as the tariff reads it: uncounted event day, free, closed, chargeable
  with tier boundaries and the expiry line.
- Controls for `firstDay`, `basis`, free days and tiers, plus a weekend picker
  and holiday list on the working basis. The outputs are the real
  `freeTimeExpiry` and `chargeableDays` calls; every preset is a JSDoc example
  and the mount test asserts the printed literal and that the coloured cells
  equal `freeDaysUsed` and `chargedDates`.
- A zoneless wall time from the chat is read in the named zone with
  `disambiguation: reject`. The permalink carries every list and number as a
  string, because `seedFromLocation` passes only strings.
- Registered in the widget registry, permalink kinds, Worker tools, the
  html-diff and visual gates, forced-colours CSS, and `context/dox/built.md`
  (six tools).
- Guide: the three clocks, which day is day one, calendar or working days,
  the half-open expiry, charged dates and tiers, and what 46 CFR 541.6 makes
  an invoice print. Ported from the README section.
- Scenarios: free time start day (`freeTimeExpiry`), demurrage across a
  weekend (`chargeableDays`), detention is not demurrage (`demurrageClock`),
  each ending with what to edit in the widget.
- Mistakes: a defaulted `firstDay`, a per-port free-time table, counting in
  UTC, charging the gate-out at `expiresAt`, working days without a calendar,
  detention from discharge, hours divided by 24, rates inside the day count.
- Index entries in the industries, guides and mistakes indexes. Every result
  shown was run against dist.
- The INT-12 spec lists its docs-site pages and the Free Time Ledger, renames
  `expiredAt` to `expiresAt`, and records the decisions of record: `freeDays: 0`
  handling, one walker for both bases, half-open used and charged days, every
  tier band listed, and type names that avoid a case-only clash.
- Tracker row 10 is Done; `pnpm deps:sync` clears INT-58's blocker.
The Dox chat was slow and gave no clue where the wait went.

- Each answer now records how long each stage took: ledger read,
  corpus fetch, search, prompt, each brain tried, first token and
  total. It also records the widget tool called, if any. This shows
  in the retrieval trace and as a `dox-timing` line in the Worker log.
- Gemini brains run with `thinkingLevel: low`, set per brain.
- `maxRetries` is 0. A spent brain now fails over at once, without
  the SDK's 2 s backoff first.
- The MiniSearch index is built once per corpus array, not on every
  request. The parsed corpus is kept in memory for the cache TTL, so
  a warm request no longer re-parses 750 KB of JSON.
- A widget whose library fails to load now throws `WidgetLoadError`.
  It no longer mounts controls that look live and do nothing.
- The chat's clocks import gmt by module, not from the root barrel,
  which re-exports the whole Temporal polyfill.
…ts loudly

The Dox chat was slow and gave no clue where the wait went. Widgets
whose library failed to load showed controls that did nothing.

Chat speed:
- Each answer now records how long each stage took: ledger read,
  corpus fetch, search, prompt, each brain tried, first token and
  total. It also records the widget tool called, if any. This shows
  in the retrieval trace and as a `dox-timing` line in the Worker log.
- Gemini brains run with `thinkingLevel: low`, set per brain.
- `maxRetries` is 0. A spent brain now fails over at once, without
  the SDK's 2 s backoff first.
- The MiniSearch index is built once per corpus array, not on every
  request. The parsed corpus is kept in memory for the cache TTL, so
  a warm request no longer re-parses 750 KB of JSON.
- The chat's clocks import gmt by module, not from the root barrel.

Widget loading:
- A mount whose library fails to load throws `WidgetLoadError`
  instead of returning an inert handle.
- Every page shell catches it and calls `showUnavailable`. The widget
  is dimmed and disabled, with a notice that offers a reload.
- In the chat rail, a widget shows as busy until it is wired. A load
  failure offers Try again. A bad argument does not.

context/dox/built.md records all of the above. It also records that
the widget registry still bundles every mount and the Temporal
polyfill into the chat chunk.
…tabs

stats.mjs derives the industry layers from guides/industries/ (one guide per
layer, named for its namespace) and writes them to gmt-stats.json. The API
surface chart toggles between the core namespaces and the industry layers.
Each tab carries its own caption and follow-on paragraph through a new
after-<view> slot on ChartContainer, which the text surfaces unwrap.
…tariffs (INT-12, #193)

Research against the regulations and published tariffs (record in
context/domination/research/int-12-demurrage-conventions.md) showed that no
world standard counts demurrage days, and that the first draft modelled two
things wrongly.

- `chargeableDays` takes a required `chargeBasis`. Tariffs mostly count free
  time in working days and then charge every calendar day (CMA CGM US,
  Hapag-Lloyd US, ACL); California law charges working days only. The first
  draft let `basis: working` also suspend charging, which under-bills the
  common shape.
- `demurrageClock` takes a required `direction` and covers the export leg
  (`emptyReleased`, `gatedIn`, `loaded`) and a `combined` scope, the merged
  clock Maersk, CMA CGM and MSC sell. Event names map to DCSA Track & Trace
  equipment events.
- Review fixes: the walker only advances to later dates (Goose Bay first pass
  of 7 November no longer frees or charges the 6th), `tiers` and `freeDays`
  reject holes and unsafe integers, an event list with a hole is rejected, and
  probe rows for Manila, Lord Howe, Casey and the range limit are pinned.
- JSDoc labels 46 CFR 541.6 as the US invoice rule, not a world standard.
…cks on the site (INT-12, #193)

- Guide, scenarios, mistakes page, README and skill show `chargeBasis` and the
  export and combined clocks, every result checked against dist. A new mistake
  covers assuming working-day free time means working-day charges.
- The US invoice rule (46 CFR 541.6) is labelled as US-only everywhere; the
  guide says no world regulation exists and notes the US and Indian wording
  differences.
- Free Time Ledger: a charged-days basis control, presets for the common
  working-then-calendar shape and the California shape, permalinks and the
  `showFreeTimeLedger` chat schema carry `chargeBasis`.
- Spec, painpoints and a cited research record carry the decisions and their
  jurisdiction.
@craig-o-curtis craig-o-curtis added this to the Domination milestone Sep 24, 2026
@craig-o-curtis craig-o-curtis added documentation Improvements or additions to documentation enhancement New feature or request gmt labels Sep 24, 2026
@craig-o-curtis craig-o-curtis changed the title Feature/193 int 12 freetimeexpiry chargeabledays demurrageclock Feature/193 int 12 freeTimeExpiry chargeableDays demurrageClock Sep 24, 2026
@craig-o-curtis craig-o-curtis changed the title Feature/193 int 12 freeTimeExpiry chargeableDays demurrageClock feat(intermodal) INT-12 freeTimeExpiry chargeableDays demurrageClock #193 Sep 24, 2026
…ed starters

A single busy model failed the whole Dox answer, the reader saw nothing
while Dox walked its brains, and in dev a late dependency re-bundle broke
widget imports mid-load.

Worker:
- A 503 or 529 from a model now moves to the next brain without marking
  it in the ledger. If every brain was out or busy, the reader gets a
  retryable refusal, not `allowance used`. A 500 still stops at once.
- The stream opens before a brain is chosen. Each step goes out as a
  transient `data-status` part, and a refusal as a transient
  `data-refusal` part carrying the old HTTP status and payload.

Chat:
- The waiting card shows the Worker's progress. A refusal goes through
  the same `classifyChatError` and leaves no empty turn.
- Starter pills open their widget on click, with seeded arguments that
  are checked against each tool's schema. The model's own call replaces
  the widget unless its tool and arguments match.
- The widget registry loads template and mount together through one
  `import()`. It imports only types from the mount modules, so no mount
  is in the first download. `widget-graph.test.ts` guards this.
- The panel shows a loading placeholder until the widget is wired.
- Chat helpers import gmt by module path, not the root barrel.

Dev:
- Vite scans `src/lib` at startup, so widget and chart packages are
  found before any page imports them. No mid-load re-bundle or reload.

context/dox/built.md records all of the above, with the measurements.
…INT-12, #193)

Fixes from the second gmt-reviewer pass.

- `freeTimeExpiry` and `chargeableDays` no longer return `null` for a
  clock start at the minimum instant west of UTC. The event day began
  before Temporal's first instant, so `localDayOf` now anchors that day
  at the clock start. `null` is returned only when that unrepresentable
  start would itself be the expiry (`freeDays: 0`). Pinned with
  Etc/GMT+12, America/New_York and Etc/GMT-14 rows.
- Working-day free time with calendar-day charges is now called the
  usual US shape, not a universal one. Outside the US both are mostly
  calendar days. 46 CFR 541.6 is labelled the US invoice rule.
- `no counting term has a default` replaces `none has a default`. The
  DCSA glossary is now the storage citation. The `freeDays: 0` wording
  is corrected, and the export clocks name the tariffs that quote them.
- Shorten the gmt-timezone skill description to Intent's 1,024-character
  limit, and name the combined clock and both legs in the skill tree.
- Sync published test counts.
…-12, #193)

Apply the second review's wording fixes to the site.

- The guide, mistakes page, weekend scenario, Free Time Ledger page and
  preset, and the chat tool hint now say that working-day free time
  with calendar-day charges is the usual US shape. Outside the US both
  are mostly calendar days, and California charges working days for
  free time and charges alike.
- Drop `the dates an invoice has to print` as a universal claim. Only a
  US invoice must print them.
- Say which tariffs quote the export clocks, and that export storage
  follows from the DCSA definition of storage.
- Reflow the Free Time Ledger mount and its test.
Line wrapping only, from the repo formatter. No code changes: each file
matches its previous version once whitespace and trailing commas are
ignored.
@craig-o-curtis craig-o-curtis linked an issue Sep 24, 2026 that may be closed by this pull request
@craig-o-curtis
craig-o-curtis enabled auto-merge (squash) September 24, 2026 13:08
@baldurpan

Copy link
Copy Markdown
Contributor

Review

I ran pnpm run validate on the branch: exit 0, as the description says. I also wrote a throwaway differential probe for the day walker — an independent oracle that finds the terminal's real local days by PlainDate iteration and toZonedDateTime round-tripping, instead of by zonedBucket — and compared it against freeTimeExpiry and chargeableDays over 6,272 combinations: 15 zones (the battle-test set plus Apia, Goose Bay, Santiago, Manila, Lord Howe, Casey, Chatham, Havana), seven clock starts sitting on transitions, both bases, both charge bases, both firstDay values and freeDays of 0, 1, 3 and 7. Zero mismatches on the expiry instant, the free days used and the charged-date list. The deleted date, the re-entered date and the no-step-backwards rule all hold. Nice piece of work.

One blocking finding, and it is not in the walker.

Blocking: all three new functions throw on a hostile options bag

Core Rule 3 says invalid input returns the sentinel and never throws. These three throw:

freeTimeExpiry  arg2 (options)  Proxy that throws on any trap  → TypeError: hostile proxy
freeTimeExpiry  arg2 (options)  revoked Proxy                  → TypeError: Cannot perform 'get' on a revoked proxy
chargeableDays  arg3 (options)  Proxy that throws on any trap  → TypeError: hostile proxy
chargeableDays  arg3 (options)  revoked Proxy                  → TypeError: Cannot perform 'get' on a revoked proxy
demurrageClock  arg0 (events)   revoked Proxy                  → TypeError: Cannot perform 'IsArray' on a revoked proxy
demurrageClock  arg2 (options)  Proxy that throws on any trap  → TypeError: hostile proxy
demurrageClock  arg2 (options)  revoked Proxy                  → TypeError: Cannot perform 'get' on a revoked proxy

These are the exact garbage values in src/test/noThrow.ts's GARBAGE list — the two added by CORE-8 (#253) precisely because "a list of remembered shapes cannot see what is not on it".

The cause is that the reads happen before the try:

  • freeTimeLedger.ts:58 destructures options at the top of parseFreeTimeTerms, outside any try.
  • freeTimeExpiry.ts:81 and chargeableDays.ts:119 call parseFreeTimeTerms before their own try.
  • demurrageClock.ts:144 calls Array.isArray(events), :151 destructures options, and :164 reads the event list — all outside the try at the bottom.

I prototyped the fix locally and it clears every row above: wrap the destructure in parseFreeTimeTerms in a try/catch returning null, and move demurrageClock's body into a helper called from a single top-level try/catch. The existing 275 intermodal tests stay green.

One more of the same class that the harness does not reach: chargeableDays.ts:121 reads options.tiers outside the try, so a throwing tiers getter escapes too. Moving that read inside covers it.

Why CI did not catch this

src/test/noThrow.test.ts:58 lists the namespaces to fuzz by hand:

calendar duration instant interval plain precision span unix utc zoned

intermodal is not on it — and neither is transport. The coverage guard just above it only asserts that every export has a corpus @example baseline, so it cannot see a namespace missing from the describe.each. Adding both rows is the fix that keeps the next namespace from slipping through the same gap. Would it be worth deriving the list from the corpus's distinct namespaces instead of typing it?

Adding transport surfaces a pre-existing one, already on main from TRAN-8 (#279), so it is not this PR's to fix — but it is the same shape and worth its own issue:

transitTime  arg0  { toString() { throw } }      → Error: hostile toString
transitTime  arg0  Object.create(null)           → TypeError: Cannot convert object to primitive value
transitTime  arg0  Symbol()                      → TypeError: Cannot convert a Symbol value to a string
transitTime  arg0  Proxy that throws on any trap → TypeError: hostile proxy
transitTime  arg0  revoked Proxy                 → TypeError: Cannot perform 'get' on a revoked proxy

Minor: the corpus memo can pin itself for the life of the isolate

apps/dox/src/lib/retrieval/fetch-chunks.ts. unixNowOrExpired returns +Infinity when getUnixNow() reports its null sentinel, and the comment says an unreadable clock "must expire the memo … never keep it forever". The read does behave that way. The write does not: remember stores expiresAt: now() + cacheTtlSeconds * 1000, which is Infinity when the clock is unreadable at that moment. Once the clock recovers, Infinity > now() is true on every later request, so that isolate serves the same corpus until it is recycled.

getUnixNow() only returns null if Temporal.Now.instant() throws, so this is latent rather than live. Storing expiresAt: 0 on an unreadable clock, or computing it from a clock read that is required to succeed, would match what the comment promises.

Checked and fine

  • dwellTime.ts and etaAtZone.ts: formatter reflow only, no behaviour change.
  • b6255bb and 2cd5bae: whitespace only, confirmed.
  • No Date, no Temporal objects crossing the public boundary, no plain/zoned mixing; the walker returns the sentinel on exhaustion rather than a partial ledger.
  • No it.fails / .skip / .todo / .only anywhere in the new tests. it.each(battleTestTimeZones.map(...)) at chargeableDays.test.ts:343 is array syntax, but it matches the 30 existing zone sweeps in the package, so I read that as the house idiom rather than a finding.
  • The data-refusal part reaches classifyChatError through the same { status, payload } shape, and is transient, so a refused request leaves no empty turn. Worth noting for anyone watching the endpoint that a spent allowance is now HTTP 200 with an in-stream refusal rather than a non-OK status.

Everything but the blocking section is take-it-or-leave-it.

…193)

Review of PR #281: all three intermodal functions and `transitTime`
threw on values from the no-throw harness, breaking Core Rule 3.

- `freeTimeExpiry`, `chargeableDays` and `demurrageClock` read their
  arguments only inside a never-throw guard. A Proxy that throws on any
  access, a revoked Proxy, or a throwing `tiers`, `chargeBasis`,
  `direction` or event `at` getter now returns `null`.
- `transitTime` refuses a non-string before its regex checks coerce it,
  so a throwing `toString`, `Object.create(null)`, a symbol and both
  Proxies return an empty string. `etaAtZone` and `dwellTime` now run
  their checks inside their guard too. The pending TRAN-8 changeset
  says all three never throw.
- The no-throw harness derives its namespaces from the corpus instead
  of a hand-typed list, which had missed `intermodal` and `transport`.
  A guard test fails if any namespace has no cases.
- Hostile-input rows in each intermodal test file and the transport
  annotation matrix.
Review of PR #281: when the clock returned its null sentinel at write
time, the memo stored an expiry of `Infinity` and kept that corpus for
the life of the isolate. It now stores an already-expired entry, so the
next request re-reads once the clock recovers.

Also note in context/dox/built.md that a pool spent or every brain busy
during an answer is now HTTP 200 with the refusal in the stream.
@craig-o-curtis

Copy link
Copy Markdown
Contributor Author

@baldurpan thanks for the differential probe. The fixes are in:

  • Blocking: all three intermodal functions now read their arguments only inside a never-throw guard. Your seven Proxy rows return null, as do a throwing tiers, chargeBasis, direction or event at getter. Each has a row in the function's own tests.
  • The harness now builds its namespace list from the corpus's function entries, with a guard that fails if a namespace has no cases. It picks up intermodal and transport, and before the fix it failed exactly your four functions.
  • transitTime: fixed here rather than in a separate issue, since TRAN-8 hasn't been released yet. It refuses a non-string before its regex checks, and etaAtZone and dwellTime got the same guard. The pending TRAN-8 changeset says so.
  • Corpus memo: an unreadable clock at write time now stores an already-expired entry. There's a test for the Infinity-then-recovered case.
  • HTTP 200 refusal: noted in context/dox/built.md for anyone watching the endpoint.

pnpm run validate passes: 36,880 gmt tests, 4,529 dox tests.

@baldurpan baldurpan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🏸

@craig-o-curtis
craig-o-curtis merged commit d896085 into main Sep 24, 2026
38 checks passed
@craig-o-curtis
craig-o-curtis deleted the feature/193-int-12-freetimeexpiry-chargeabledays-demurrageclock branch September 24, 2026 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request gmt

Projects

None yet

Development

Successfully merging this pull request may close these issues.

INT-12: freeTimeExpiry + chargeableDays + demurrageClock

2 participants