Skip to content

feat(runtime): caller-aware access hook that ownership does not pre-empt - #8662

Closed
MusabMahmoodh wants to merge 18 commits into
jaseci-labs:mainfrom
MusabMahmoodh:feat/caller-aware-access-hook
Closed

feat(runtime): caller-aware access hook that ownership does not pre-empt#8662
MusabMahmoodh wants to merge 18 commits into
jaseci-labs:mainfrom
MusabMahmoodh:feat/caller-aware-access-hook

Conversation

@MusabMahmoodh

Copy link
Copy Markdown
Contributor

Summary

Fixes #8459.

__jac_access__ is invoked with zero arguments, so an archetype policy can say
what every non-owner gets but never what this caller gets; and the owner
fast-path returns WRITE three lines before the hook is consulted, so for
anything the caller's root owns the hook never runs at all. Together those made
a role such as "viewer of this team's doc" expressible only as an endpoint
check - the very thing an ACL-in-traversal design exists to make unnecessary.
The ordering is also a blocker for any group design: bind members to a shared
root and every member is an owner, so policy never runs (the section-6 point on
#8332).

Archetypes may now declare __jac_access_for__(caller: UUID), consulted
before the owner fast-path: ownership admits, the role decides.

  • A returned level is final for that caller - so the owning root can be held to
    READ, which is what makes org-style roles expressible at all.
  • Returning None falls through to the normal resolution (ownership, then
    stored grants), so conditional policies still compose.
  • The system root keeps its bypass (and jroot == to), so the platform
    cannot be locked out of its own graph.
  • Archetypes declaring only the zero-arg hook, or neither, resolve exactly as
    today; the arity is resolved by comparing against the base implementation,
    not by inspecting signatures per access check. When both are declared the
    caller-aware one wins.
  • Documented in jac-sv-multi-user beside the existing __jac_access__
    section.

Test (real CLI runner, three real roots, no endpoint checks anywhere)

tests/runtimelib/test_access_hook_caller.jac:

  1. The hook sees the caller: TeamDoc grants WRITE to listed editor roots
    and READ to everyone else. The editor's cross-user write persists; the
    viewer's write is dropped at the seam (no handler check exists in the
    fixture) while both can still read.
  2. Ownership admits, role decides: the owner is not in editors, so the
    owner's own write is refused.
  3. No regression: an archetype with no hook behaves exactly as before -
    owner writes, stranger cannot resolve.

Pre-fix: 2 of 3 fail (owner bypassed the caller-aware policy, and the
hook not being consulted at all leaves the doc unreadable to non-owners).
Post-fix: 3 passing.

Neighbors re-run on the fix: test_permission_diagnostics 6/6,
test_jaseci 18/18, and test_acl_pushdown 4/4 - the last one matters
because this PR reorders check_access_level, which the #8598 pushdown work
mirrors in SQL.

Could this have been less code?

  • The whole change is a new default hook plus a reordering inside
    check_access_level; no new Principal object, no context change. The
    caller's root id is already in hand at that point.
  • Rejected passing a Principal struct (the RFC's 4.1 shape): it needs
    ExecutionContext to carry an org binding, which is the open question on
    RFC: Organizations and multi-user collaboration from first principles: the tenancy primitive jac-scale SaaS apps are hand-rolling #8332 and not settled. A UUID is the part that exists today, and a richer
    principal can be added later without breaking this signature's callers.
  • Rejected overloading __jac_access__ by signature inspection: an arity
    probe per access check is hot-path cost and ambiguous for inherited hooks; a
    separate name resolved by an identity comparison against the base is cheaper
    and unambiguous.
  • Rejected gating the reorder behind a config flag: the reorder only takes
    effect for archetypes that declare the new hook, so it is already opt-in by
    construction.

Deleted

  • Nothing. The zero-arg hook, its semantics, and its docs stay - they are
    correct for type-wide policy. No test asserted the old ordering (the owner
    fast-path had no coverage that a caller-aware hook could contradict), and no
    config key or fallback became dead.

What else could this break

Grepped __jac_access__, check_access_level, no_custom, access_level_cast:

  • Archetypes with an existing zero-arg hook: unchanged path, unchanged
    order (still after the owner fast-path). Covered by test 3 and by
    test_permission_diagnostics.
  • The owner fast-path: now runs after the caller-aware hook only when one
    is declared
    . For every archetype in the tree today (none declare it), the
    emitted decision is identical.
  • no_custom=True callers: skip both hooks exactly as before.
  • SQL pushdown ([Arch] Graph traversal: page the adjacency, fold the query, make object-space cost visible (#8595) #8598): an archetype with a caller-aware hook has no SQL
    form, the same as one with __jac_access__ today - those keep the
    object-space filter. test_acl_pushdown passes unchanged; the two must
    continue to agree, which is why that suite is in the evidence above.
  • Performance: one identity comparison against the base method per access
    check on non-owned anchors; no signature inspection, no allocation.
  • Recursion: a hook that itself reads a foreign persistent anchor recurses
    into the gate, bounded by the recursion limit (loud) - the same shape as an
    existing __jac_access__ that does so.
  • Monolith vs microservice, kind vs EKS, embedded vs external DB, replica
    count
    : the change is pure in-process policy resolution; no storage,
    transport, or deployment surface is touched.

MusabMahmoodh and others added 10 commits August 24, 2026 16:13
__jac_access__ is invoked with no arguments and the owner fast-path
returns WRITE before it runs, so an archetype policy can say what every
non-owner gets but never what this caller gets, and a role such as
viewer-of-this-team was only expressible as an endpoint check. Both
facts block any group design: bind members to a shared root and every
member is an owner, so policy never runs.

Archetypes may now declare __jac_access_for__(caller), receiving the
calling root's UUID. It is consulted before the owner fast-path, so
ownership admits and the role decides; a returned level is final for
that caller, None falls through to ownership and stored grants. The
system root keeps its bypass. Archetypes that declare only the zero-arg
hook, or neither, resolve exactly as before; when both are declared the
caller-aware hook wins. Documented in the jac-sv-multi-user skill.

Fixes jaseci-labs#8459
jaseci-labs#8745 made Anchor.id optional and added ensure_id() -> UUID, so passing
jroot.id straight into a UUID parameter no longer checks. The override
probe also read __jac_access_for__ off the result of type(), which the
checker sees as bare type. Both go through the declared surfaces now.

The test helper took a mutable list default, which the mutable-default
rule rejects.
jac fmt --lintfix rewrites getattr(obj, 'attr', None) to the null-safe
form, so write it that way directly.
@MusabMahmoodh

Copy link
Copy Markdown
Contributor Author

CI note: the red lanes on this PR are not from this branch. Recording the
measurement here so a reviewer does not bounce it, and so it is not
re-diagnosed from scratch.

0.37.0 merged this morning (#8792). Two independent problems arrived with it,
both confirmed on PRs of mine that cannot possibly cause them:

1. passes-native is flaky. My #8654 is a docs-only PR - one markdown
file - and it failed passes-native with a compiler re-entrancy error:

CompilerSourceError: compiling jaclang/compiler/symbol_utils.jac re-entered it: a pass imported 'jaclang.compiler.symbol_utils' while Python was still executing that module

A markdown file cannot cause that. Both #8654 and #8664 went
failure -> success on an empty commit with no source change.

2. Whole-tree jac check fails on one random file per run. jac-check
runs jac check unscoped on PRs by design - ci.yml says so explicitly: "a
type error is a property of the whole program, so a scoped run answers a
different question than the push sweep."
Across four of my PRs it reported
912 passed, 1 failed every time, on a different file each time, none of
them touched by the PR:

PR file it failed on error shape
#8657 scale/identity/impl/user_manager.impl.jac Cannot return <Constants.SUPER_ROOT_UUID>, expected str | NoneType
#8660 cli/commands/impl/execution.impl.jac ExecutionEngine | NoneType not narrowed
#8662 runtime/na_stdlib/urllib/request.jac Cannot assign <Unknown> to bytes
#8664 lsp/server/impl/engine.impl.jac assorted <Unknown> / type[T]

Same 913 files, same "912 passed, 1 failed", different victim each run, and
every error is the same shape: a type that should be known resolving as
<Unknown> or as an over-narrow literal. That is one non-deterministic
failure landing randomly, not four latent defects - and it is why I have
not "fixed" user_manager.impl.jac, which would have papered over a
checker bug with a str() cast.

Other people's PRs look green only because their runs predate 0.37 (newest is
Aug 30 21:21Z; 0.37.0 merged 02:28Z today). Every open PR should expect this
on its next rebase.

Happy to file the jac check non-determinism separately with the four-run
table if that is useful - flagging rather than filing, since the work pool and
stub catalog are new this release and may already be known.

jac check's fork work pool assigns files to workers differently on every
run, and a worker serving a file with an incomplete stub prelude reports
stdlib types as Self/<Unknown>. One unrelated file fails per run. No
source change here.
@MusabMahmoodh
MusabMahmoodh force-pushed the feat/caller-aware-access-hook branch from aa713db to 3a8c178 Compare September 4, 2026 06:37
@MusabMahmoodh MusabMahmoodh reopened this Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants