expires_ns is evaluated against the caller’s clock (L4 auth / ACL)

An ACE may carry an absolute deadline: expires_ns, nanoseconds since the UNIX epoch, with 0 meaning never. It is not a timer and nothing sweeps it — the policy compares it to the now its caller passed in, on every single check (ADR-0050).

The example runs against the pure surface first, because effective_acl_t takes now as a parameter — so it can step the clock without sleeping — and then confirms the identical rule at the real graph_t door, which reads its own clock.

What to notice

  • One merged list, four verdicts, zero invalidations. The example asks the same effective_acl_t about several nows, including going back to an earlier one. That is why the graph caches the effective-ACE merge but never a verdict: a merge stays valid as the clock moves, so expiry needs no invalidation mechanism at all.

  • Expiry is expires_ns <= now, not <. At the deadline the grant is already gone. The example pins both sides of that single nanosecond.

  • An expired ACE grants nothing — and still closes the vertex. Presence is what closes (open by default), and an expired entry is present. So a temporary grant that lapses does not restore the open state it replaced; it leaves the vertex shut to everyone. This is the trap: “the badge expired, so we are back to normal” is exactly wrong.

  • 0 means never expires, not expired at the epoch. The example checks that too, because the two readings of a zero field differ by the entire lifetime of the grant.

  • The parser refuses an empty expires_ns for the same reason. An empty payload would load as 0 — the permissive reading — so an absent value must not be allowed to mean “never expires” (strict ACL parsing).

  • Nothing here is conditional, and nothing sleeps. No timers, no threads, no wall-clock rendezvous: the deadline is a number the example passes in. The graph-level half uses grants that expired one nanosecond after the epoch and one that expires in the year 2262, so it is deterministic on any machine, on any CI leg.

Source

  1/*
  2 * SPDX-License-Identifier: Apache-2.0
  3 * SPDX-FileCopyrightText: Copyright 2026 avatarsd LLC
  4 */
  5
  6/**
  7 * @file
  8 * @brief ONE CONCEPT — `expires_ns` is evaluated against the CALLER's `now`, at check time.
  9 *
 10 * An ACE may carry an absolute deadline: `expires_ns`, nanoseconds since the UNIX epoch, with
 11 * `0` meaning "never". It is not a timer and nothing sweeps it — the policy compares it to the
 12 * `now` its caller passed in, on every single check. That is why the graph caches the merged
 13 * effective-ACE list but never a verdict: a merge stays valid as the clock moves, so expiry
 14 * needs no invalidation mechanism at all (ADR-0050).
 15 *
 16 * The consequence to hold on to is the one that catches people out: an expired ACE grants
 17 * nothing AND still closes the vertex. Presence is what closes (see acl_open_by_default), and
 18 * an expired entry is present. A temporary grant that lapses therefore does not restore the
 19 * open state it replaced — it leaves the vertex shut to everyone.
 20 *
 21 * This runs against the PURE surface — `effective_acl_t` takes `now` as a parameter, so the
 22 * example can step the clock without sleeping — and then confirms the same rule at the real
 23 * `graph_t` door, which reads its own clock.
 24 *
 25 * Runs under ctest as `example_acl_expiry`; returns non-zero on any failed check.
 26 */
 27
 28#include <cstdint>
 29#include <cstdio>
 30#include <cstring>
 31#include <span>
 32#include <string_view>
 33#include <vector>
 34
 35#include "libtracer/graph.hpp"
 36#include "libtracer/mem_heap.hpp"
 37#include "libtracer/security_acl.hpp"
 38
 39namespace {
 40
 41using tr::graph::ace_t;
 42using tr::graph::acl_right_t;
 43using tr::graph::effective_acl_t;
 44using tr::graph::graph_t;
 45using tr::graph::path_t;
 46using tr::graph::role_t;
 47using tr::graph::status_t;
 48using tr::graph::subject_token_t;
 49using tr::graph::vertex_handle_t;
 50
 51/** @brief Report expectation @p what and record a failure on @p ok. */
 52void check(bool& ok, bool cond, const char* what) {
 53    std::printf("  [%s] %s\n", cond ? "ok" : "FAIL", what);
 54    ok = ok && cond;
 55}
 56
 57/** @brief @p s as opaque subject-token bytes. */
 58std::vector<std::byte> as_bytes(std::string_view s) {
 59    std::vector<std::byte> out(s.size());
 60    std::memcpy(out.data(), s.data(), s.size());
 61    return out;
 62}
 63
 64/** @brief The caller context IS the subject token. */
 65std::expected<subject_token_t, tr::wire::err_t> caller_is_subject(void*, std::string_view caller) {
 66    return as_bytes(caller);
 67}
 68
 69/** @brief @p right as the single `access_mask` bit it is. */
 70constexpr std::uint32_t bit(acl_right_t right) { return static_cast<std::uint32_t>(right); }
 71
 72/** @brief A one-byte VALUE. */
 73tr::view::view_t some_value() {
 74    const std::byte one[1] = {std::byte{0x01}};
 75    return *tr::view::over_bytes(one);
 76}
 77
 78/** @brief True iff @p r was refused by an ACL gate. */
 79template <class T>
 80bool denied(const tr::graph::result_t<T>& r) {
 81    return !r.has_value() && r.error() == status_t::PERMISSION_DENIED;
 82}
 83
 84/** @brief A visitor's badge, valid until @ref kDeadline. */
 85constexpr std::uint64_t kDeadline = 1'800'000'000'000'000'000ULL;  // ~2027-01-15, in ns
 86
 87}  // namespace
 88
 89int main() {
 90    bool ok = true;
 91
 92    // The pure surface: build one effective-ACE list ONCE, then ask it about several `now`s.
 93    // Nothing below rebuilds or invalidates it — that is the property being shown.
 94    const ace_t issued[] = {{.subject = as_bytes("visitor"),
 95                             .access_mask = bit(acl_right_t::READ),
 96                             .expires_ns = kDeadline}};
 97    effective_acl_t merged;
 98    merged.append_own(issued);
 99    const std::span<const std::byte> visitor_bytes = issued[0].subject;
100    const std::vector<std::byte> other = as_bytes("resident");
101
102    check(ok, merged.allows(visitor_bytes, bit(acl_right_t::READ), kDeadline - 1),
103          "one nanosecond before the deadline, the badge works");
104    check(ok, !merged.allows(visitor_bytes, bit(acl_right_t::READ), kDeadline),
105          "AT the deadline it does not — expiry is `expires_ns <= now`, not `<`");
106    check(ok, !merged.allows(visitor_bytes, bit(acl_right_t::READ), kDeadline + 1),
107          "and after it, of course, it does not");
108    check(ok, merged.allows(visitor_bytes, bit(acl_right_t::READ), kDeadline - 1),
109          "the SAME list answers yes again for an earlier `now`: no state changed, nothing "
110          "was invalidated, and nothing had to be");
111
112    // The expired entry is still an entry, so the vertex it guards stays shut.
113    check(ok, !merged.allows(other, bit(acl_right_t::READ), kDeadline - 1),
114          "the resident was never granted anything, and a present ACE closes the vertex");
115    check(ok, !merged.allows(other, bit(acl_right_t::READ), kDeadline + 1),
116          "…and the badge lapsing does not reopen it — this is the trap worth remembering");
117
118    // `0` is not a deadline in the distant past; it means the ACE never expires.
119    const ace_t permanent[] = {
120        {.subject = as_bytes("resident"), .access_mask = bit(acl_right_t::READ)}};
121    effective_acl_t forever;
122    forever.append_own(permanent);
123    check(ok, forever.allows(other, bit(acl_right_t::READ), kDeadline + 1),
124          "expires_ns == 0 means never expires, not expired at the epoch");
125
126    // The same predicate at the real door, where `now` comes from the graph's own clock.
127    graph_t g;
128    g.configure_subject_resolver(caller_is_subject, nullptr);
129    const vertex_handle_t v = g.register_vertex(path_t("/room"), role_t::STORED_VALUE);
130    (void)g.write(v, some_value());
131    const ace_t both[] = {
132        {.subject = as_bytes("live"),
133         .access_mask = bit(acl_right_t::READ),
134         .expires_ns = ~0ULL >> 1},
135        {.subject = as_bytes("stale"), .access_mask = bit(acl_right_t::READ), .expires_ns = 1},
136    };
137    (void)g.write(path_t("/room:acl"), *tr::view::over_bytes(tr::graph::encode_acl(both)));
138    check(ok, g.read(v, "live").has_value(), "a grant expiring in the year 2262 still reads");
139    check(ok, denied(g.read(v, "stale")), "a grant that expired 1 ns after the epoch does not");
140
141    std::printf("one ACE list, four verdicts, zero invalidations — deadline %llu ns\n",
142                static_cast<unsigned long long>(kDeadline));
143    return ok ? 0 : 1;
144}

See also: security-acl module · protocol TLVs · open by default · inheritance · strict ACL parsing.