Skip to content

fix(plugin-auth): write the session last_activity_at heartbeat on every deployment - #25398

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-pfhunc
Oct 23, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-pfhunc

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #25343

Clause-②: no
Metadata-protocol: none

The second and last half of #25343. The column half (user_agent and last_activity_at on the Sessions list views) landed as #25368. This half makes last_activity_at actually get written on a default deployment, so the Last Activity column is filled.

Ruling: 6358844136, letter A (maintainer 「同意」, 2026-10-23), per analysis 6357589247.

What changes

packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts, enforceSessionControls (run per authenticated request from customSession):

  • The heartbeat runs on every deployment. The early return when the idle and absolute limits are both off (the default) is gone, and the last_activity_at write is no longer inside if (idleMin > 0). It is still at most one write per session per minute (SESSION_HEARTBEAT_INTERVAL_MS, 60 s, the same threshold as before).
  • With both limits off, the read is throttled too. A per-process, per-session memo (SessionHeartbeatMemo) remembers when each session's sys_session row was last read and skips the findOne (and so the write) for 60 s after it. The memo is bounded at SESSION_HEARTBEAT_MEMO_MAX (10,000) sessions, least recently read evicted first; an evicted session is simply read once more. It is held in a WeakMap keyed by the data engine, so in a running process it is the one engine's memo, and a second engine (another manager, a test double) never answers from it.
  • With a limit on, nothing about enforcement changes. The memo is not consulted, so every request still reads the row, exactly as revocation needs. The heartbeat now also runs when only the absolute limit is configured (before, that configuration wrote no heartbeat either).
  • A failed read is not memoised, so the next request reads again. When both limits are off, the read-failure warning now names what was lost (the heartbeat) instead of saying the idle and absolute limits were not evaluated, which on that deployment would name controls nobody turned on. With a limit on, the message is unchanged.
  • The write-failure path (logDurabilityDegradation on the heartbeat update) is unchanged: same text, same meta, only re-indented one level.

auth-manager.ts: the enforceSessionControls docblock no longer says "No-op when both are off" (comment only).

Measured cost

Under the default config, per live session per minute:

  • One request reads sys_session once and, when last_activity_at is older than a minute, writes it once. Every other request in that minute reads nothing extra (the memo).
  • That same request resolves the caller's grants twice. Core's request-grants memo (packages/core/src/security/request-grants-memo.ts) declines when a write lands inside the request, and the heartbeat is such a write. Measured in session-grants-resolved-once.test.ts for an organization member: the declined request reads 6 more grant rows than a memo-served one (twice.reads.length - once.reads.length, the existing pin). So the price is about 6 extra grant reads per session per minute, on top of the one read and one write.
  • ADR-0127 D8 is confirmed on main (docs/adr/0127-*.md around :125): sys_session is deliberately outside the grants cache's watched set, so the heartbeat does not retire the authorization cache. The cost above is the per-request memo, not the cache.

Verification

  • New session-heartbeat-default-config.test.ts, 11 cases: heartbeat written under the default config (first request, and again after more than 60 s); the memo skips the read and the write within 60 s and reads again at 60 s + 1 ms; a row touched by another process 20 s ago is read but not written; per session; per engine; a failed read is not memoised and its warning names the heartbeat; a revoked session is read once a minute and never touched; the memo cap evicts the least recently read; with an idle timeout or an absolute max on, two requests a second apart both read.
  • auth-manager.test.ts: the pin "is a no-op when idle + absolute are both off" asserted that findOne is never called, which is the behaviour the ruling reverses. It is rewritten in place to "revokes nothing but still writes the heartbeat". The idle-timeout and absolute-max pins beside it are unchanged and green.
  • session-grants-resolved-once.test.ts: its warm-up asserted that the warm request writes nothing. A session's first request now writes the heartbeat, so the warm-up makes one request per session and expects exactly update sys_session from each; the measured requests after it write nothing, as before, and every count in the file is unchanged. The pin "a write inside the request: the memo declines" gains a default-config row beside its idle-timeout row.
  • Ablation: idleMin > 0 && put back in front of the heartbeat condition (scripts/ablation-replace.mjs, anchor 1 to 0, blob b6da2ab9 to 8519f0aa; the tests import src/, no dist/ on the path). 15 cases went red, including both "heartbeat written under the default config" pins, the rewritten no-op pin and the default-config grants-memo row. Restored: blob back to b6da2ab9, git diff HEAD empty.
  • pnpm --filter @objectstack/plugin-auth test: 188 files, 3538 passed, 10 skipped (at 302d5e29; the two commits after it change only session-heartbeat-default-config.test.ts and the ledger below, and the three touched suites re-ran at af4a5fe5: 326/326). pnpm --filter @objectstack/plugin-auth typecheck (tsc, examples, check:test-typecheck) exit 0 at af4a5fe5; the test-typecheck debt ledger is unchanged.
  • The new suite's engine double opens findOne / update with assertEngineFindOnePredicate / assertEngineUpdateDispatch, and scripts/engine-double-contract.pinned.json gains its two rows (check-engine-double-contract.mjs --write, 0 lost).
  • Gates at af4a5fe5: dispatch-gates --commands derived 79; --ran with recorded exit codes: 79 accounted, 78 run, 1 NOT MEASURED (node scripts/check-plugin-teardown-shape.mjs --self-test exits 3, it needs an unshallowed clone). pnpm check:dts-closure and pnpm check:dual-build-cjs-loads first exited 1 on a local packages/plugins/organizations/dist with no index.d.ts (a package this diff does not touch); after pnpm --filter @objectstack/organizations build --force both exit 0. pnpm check:durability-log-level and pnpm check:engine-double-contract exit 0.

Acceptance notes

  • The heartbeat's write-failure message still explains its consequence in idle-timeout terms ("this session will be revoked as idle EARLIER"). On a deployment with no idle timeout the real consequence is only a stale Last Activity. The dispatch asked for that path to stay exactly as it is, so it is unchanged here.
  • The memo is per process, as ruled. On a multi-node deployment a session served by several nodes can be read once a minute per node; the write is still guarded by the row's own last_activity_at.
  • The raw user_agent shown as browser and OS is the objectui polish item noted on the card, not part of this PR.

Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 11 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/environment-variables.mdx (via sys_session (literal, a string literal in enforceSessionControls))
  • content/docs/kernel/contracts/auth-service.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/kernel/contracts/cache-service.mdx (via sys_session (literal, a string literal in enforceSessionControls))
  • content/docs/kernel/runtime-services/audit-service.mdx (via sys_session (literal, a string literal in enforceSessionControls))
  • content/docs/kernel/services-checklist.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AuthManager (symbol, a top-level class), sys_session (literal, a string literal in enforceSessionControls))
  • content/docs/permissions/authorization.mdx (via sys_session (literal, a string literal in enforceSessionControls))
  • content/docs/ui/setup-app.mdx (via sys_session (literal, a string literal in enforceSessionControls))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-2.mdx (via sys_session (literal, a string literal in enforceSessionControls))
  • content/docs/releases/v17/17-3.mdx (via sys_session (literal, a string literal in enforceSessionControls))
  • content/docs/releases/v17/17-4.mdx (via sys_session (literal, a string literal in enforceSessionControls))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b22ab8d6fb3462bff05142efc6b6cc040c95f646 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0c4e7c993e3789d4c7c9764571a303f6534fd745 — the merge of head af4a5fe5e25f381c6e57afc93dd6057ba9cbbeeb into base b22ab8d6fb3462bff05142efc6b6cc040c95f646, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0c4e7c993e3789d4c7c9764571a303f6534fd745 && git checkout 0c4e7c993e3789d4c7c9764571a303f6534fd745
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b22ab8d6fb3462bff05142efc6b6cc040c95f646 af4a5fe5e25f381c6e57afc93dd6057ba9cbbeeb && git checkout -B drift-repro b22ab8d6fb3462bff05142efc6b6cc040c95f646 && git merge --no-ff af4a5fe5e25f381c6e57afc93dd6057ba9cbbeeb

node scripts/docs-audit/affected-docs.mjs --json b22ab8d6fb3462bff05142efc6b6cc040c95f646

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b22ab8d6fb3462bff05142efc6b6cc040c95f646 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:engine seat 1 · os-tesla · session_014Hse9cj2i4o5UAVrd21NKr · 2026-10-23T08:02Z · head af4a5fe5e · #25343 (Fixes, the second and last half; the column half landed in #25368)

  • Ruling carried: A (6358844136, maintainer 「同意」). The heartbeat runs whatever the idle timeout, with a per-process 60 s memo.

  • Shape: 7 files, +386 / −39:

    • auth-manager-password-session-policy.ts: the heartbeat leaves the idleMin > 0 branch.
    • A bounded SessionHeartbeatMemo, an LRU capped at 10,000 and held in a WeakMap per data engine, skips the sys_session read within 60 s when both limits are off.
    • One new pin file, two rewritten pins, the enforceSessionControls docblock, two engine-double-contract.pinned.json rows, and a patch changeset.

    Read from the diff. NOT GOVERNED, under the 3000-line threshold. Clause-② no: runtime behaviour only, with no authorable key.

  • Read on the diff:

    • The limits-on path is unchanged apart from the heartbeat no longer being conditional on idleMin.
    • The write-failure logDurabilityDegradation text is byte-identical.
    • The lookup-failure message now says which half failed (heartbeat versus controls).
    • No timeout or revocation decision moves.
  • Measured cost (the analysis's confidence gap, now measured): under the default config, each live session costs at most 1 sys_session read and 1 write per minute. On that one request, core's request-grants memo declines, so an org member's grants are read once more (+6 grant reads, pinned by the existing twice-minus-once pin). ADR-0127 D8 is confirmed: the grants cache is not retired.

  • Cross-lane: plugin-auth was declared on [PM seat] domain:services — ⏳ vacant · archive of domain:server seat 1 (#6367) #6021 (6358897274). No objection was posted.

  • Gates: 41 check-runs on af4a5fe5e: 34 success, 7 skipped, 0 failed; mergeable clean. The dev ran 79 derived gates, 78 of them, with 1 NOT-MEASURED (a self-test that needs an unshallowed clone, covered by CI).

  • Pins: putting idleMin > 0 back turns 15 cases red, and the restore is proven by blob.

  • Accepted deviations (approved in the thread before the PR): rewriting the auth-manager.test.ts no-op pin and session-grants-resolved-once.test.ts (the ruling reverses exactly what they asserted); the docblock fix; and the two contract-ledger rows that check:engine-double-contract requires for the new suite's double.

  • Landing: the seat marks it ready and enables auto-merge. Its Fixes closes Setup → Sessions: two of one user's sessions read identically, with no device or last activity, so the admin revoked both to end one — show the user agent and Last Activity, which sys_session already holds (hotcrm#2194 R2-T2-U05) #25343, and both halves will then have landed, as the ruling requires.


Generated by Claude Code

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 size/m tests tooling

Projects

None yet

2 participants