Skip to content

feat(runtime): save takes an explicit owner root, with an ownership accessor - #8660

Closed
MusabMahmoodh wants to merge 21 commits into
jaseci-labs:mainfrom
MusabMahmoodh:feat/save-owner-binding
Closed

feat(runtime): save takes an explicit owner root, with an ownership accessor#8660
MusabMahmoodh wants to merge 21 commits into
jaseci-labs:mainfrom
MusabMahmoodh:feat/save-owner-binding

Conversation

@MusabMahmoodh

Copy link
Copy Markdown
Contributor

Summary

Fixes #8458.

save assigned anchor.root from the ambient user_root at first save and
exposed no way to choose otherwise, so app code could mint a group Root() but
that root could never own anything: a member granted on it read nothing,
because all the "group's" content belonged to whichever user created it. This
is the primitive that decides whether any group-ownership design is reachable
from app code at all.

  • save(obj, owner=root_id) creates the anchor under the given root. The
    seam already existed - save honours a pre-set anchor.root - it was just
    unreachable, so this exposes it rather than adding a parallel path.
  • Authorization, fail-loud: the caller must hold WRITE on the target
    root's anchor (the existing check_access_level, which is also how the RFC's
    membership grant works). PermissionError otherwise; ValueError if the
    owner is not a root or does not exist; a persistent anchor cannot be
    re-owned. It never silently falls back to the caller's root.
  • Jac.owner_of(obj) answers "which root owns this?", which previously
    could only be measured by attempting a read as someone else.
  • Omitting owner is byte-for-byte today's behavior.

One sharp edge found while testing, documented rather than papered over: a
minted Root() arrives already-persistent with no owner, so nobody - not even
its creator - can reach it afterwards. The mint pattern must grant the creator
WRITE on it in the same function, while the reference is still in hand. That
is now in the jac-sv-persistence skill next to the group-sharing section. I
deliberately did not make save auto-grant this: a silent implicit grant at a
security boundary is the class of behavior these issues are about.

Test (real CLI runner, multiple real roots, no mocks)

tests/runtimelib/test_save_owner.jac, 3 tests / 3 passing, all through
execution.run with distinct real roots:

  1. Content created with owner=org is owned by the org (owner_of confirms),
    an ungranted user reads nothing (control), and one allow_root on the
    org root then makes it readable - the cascade, end to end.
  2. Authorization: a READ member and a stranger are both denied
    (PermissionError); the same user succeeds once granted WRITE.
  3. A persistent anchor cannot be re-owned; a non-root owner is a ValueError.

Test 1 fails pre-change at compile (save has no owner parameter) - the
capability did not exist.

Could this have been less code?

  • Reused the pre-set-root seam in save (the issue points at it) and
    reused check_access_level for the authorization, so no new permission
    concept enters the model.
  • Rejected a second ownership field or a parallel owner map: anchor.root
    stays the single source of truth and idx_anchors_root already indexes it.
  • Rejected a with Jac.acting_for(root) context manager (the issue's other
    suggested shape): it needs ExecutionContext to hold more than one root,
    which is the RFC's open question and not settled; the parameter form gets
    the same capability with no context change.
  • Rejected auto-granting the creator on a minted root: see the sharp edge
    above.

Deleted

  • Nothing. No branch, key, or test became dead: owner is additive and its
    absence preserves the existing assignment exactly.

What else could this break

Grepped JacPersistence.save, Jac.save(, anchor.root, owner_of:

MusabMahmoodh and others added 14 commits August 24, 2026 16:07
…ccessor

Every anchor created during a request took its owner from the ambient
user root at first save, with no parameter, context, or accessor to
choose otherwise. A group root could be minted but could never own
anything, so a grant on it covered nothing and any group-ownership
design was unreachable from app code.

save(obj, owner=root_id) creates the anchor under the given root when
the caller holds WRITE on that root's anchor (the creator passes by
ownership, members by an allow_root grant on the root, and the cascade
then extends every root-level grant to the new content). The check
fails loud with PermissionError, an owner that is not a root or does
not exist is a ValueError, and a persistent anchor cannot be re-owned.
owner_of(obj) reads which root owns an anchor. Omitting owner keeps
today's behavior everywhere.

Fixes jaseci-labs#8458
# Conflicts:
#	jac/jaclang/runtime/impl/runtime.impl.jac
The save declaration fits on one line at 85 chars, so the manual wrap was
the formatting delta. _run carried a mutable list default, which the
mutable-default rule rejects; it takes None and substitutes an empty list
inside instead.
The wrapped assert was the last thing jac fmt wanted to rewrite in this
file. Naming the value keeps both lines under the width and matches the
cascade assertion a few lines down.
The fence was bare top-level statements, so test_guide could not parse it,
and it read the root id off .__jac__, which Root does not expose. It is a
declared function now and takes the id through jid(), matching the other
fences in this guide. Verified with jac check.
Interface block: kept `save(obj, owner)` and `owner_of`, took 0.37's
destroy family (`destroy(objs, strict)`, `_destroy_anchors`, `peek_attr`,
`peek_item`) whole - the old single-shape `destroy` this branch carried is
superseded by jaseci-labs#8776's one-rule del.

The save body merged on its own and now runs through 0.37's `ensure_id()`
and `all_edges()`; the owner check sits after identity is minted, which is
where it has to be now that `Anchor.id` is None until something persists.
@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 reopened this Sep 7, 2026
`filename` was renamed to `target` on main, so the test file no longer
compiled and test-runtime errored out on it.
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