simon824 commented on code in PR #380:
URL: https://github.com/apache/hugegraph-ai/pull/380#discussion_r4336344056


##########
THIRD_PARTY.md:
##########
@@ -0,0 +1,29 @@
+# Third-party components
+
+| Component | Source | Merged at | Sync policy |
+|---|---|---|---|
+| Apache HugeGraph AI (hugegraph-llm, hugegraph-mcp, hugegraph-ml, 
hugegraph-python-client, vermeer-python-client) | 
https://github.com/apache/hugegraph-ai | main@`9922568` ("fix(llm): preserve 
semantic embedding batch order"), merged via subtree into `third_party/` | 
**Never edit files under `third_party/` directly.** Upstream fixes arrive as 
subtree merges of an upstream ref; unavoidable local changes go through 
`third_party/patches/` and MUST be registered here. |
+
+## Acceptance invariants (merge-plan §1.7)
+
+- `git grep -rn "hugegraph_llm\|hugegraph_ml" server/ extensions/ tests/` → 
zero hits (no import coupling)
+- `pyproject.toml` / `uv.lock` carry no dependency on third_party packages
+- `third_party/` is excluded from ruff and from the default CI gate (a smoke 
job only checks presence + LICENSE)
+
+## Curation record (M0)
+
+Repo-machinery files that only function at a repository root were removed from
+the vendored copy: `.asf.yaml`, `.github/`, `.gitattributes`, 
`.licenserc.yaml`,

Review Comment:
   Obsolete: the branch is now based directly on apache/hugegraph-ai main, and 
`third_party/` is dropped entirely (the module lives at `ontogeny/` as a 
root-level sibling). THIRD_PARTY.md and the vendored copy are gone; there is no 
pruning record to reconcile.



##########
THIRD_PARTY.md:
##########
@@ -0,0 +1,29 @@
+# Third-party components
+
+| Component | Source | Merged at | Sync policy |
+|---|---|---|---|
+| Apache HugeGraph AI (hugegraph-llm, hugegraph-mcp, hugegraph-ml, 
hugegraph-python-client, vermeer-python-client) | 
https://github.com/apache/hugegraph-ai | main@`9922568` ("fix(llm): preserve 
semantic embedding batch order"), merged via subtree into `third_party/` | 
**Never edit files under `third_party/` directly.** Upstream fixes arrive as 
subtree merges of an upstream ref; unavoidable local changes go through 
`third_party/patches/` and MUST be registered here. |
+
+## Acceptance invariants (merge-plan §1.7)
+
+- `git grep -rn "hugegraph_llm\|hugegraph_ml" server/ extensions/ tests/` → 
zero hits (no import coupling)
+- `pyproject.toml` / `uv.lock` carry no dependency on third_party packages
+- `third_party/` is excluded from ruff and from the default CI gate (a smoke 
job only checks presence + LICENSE)
+
+## Curation record (M0)
+
+Repo-machinery files that only function at a repository root were removed from
+the vendored copy: `.asf.yaml`, `.github/`, `.gitattributes`, 
`.licenserc.yaml`,
+`.pre-commit-config.yaml`. The upstream `.gitignore` was merged into the root
+`.gitignore` (entries scoped under `third_party/`). Everything else — code,
+LICENSE, NOTICE, README, workspace `pyproject.toml` — is kept byte-for-byte.
+
+The upstream NOTICE was merged into the root NOTICE (attribution and the
+2022-2026 copyright line are incorporated verbatim); the vendored copy of
+the file was therefore removed. `third_party/LICENSE` is retained as-is.
+
+The upstream root CI workflows (hugegraph-llm.yml, hugegraph-python-client.yml,

Review Comment:
   Obsolete: the branch is based on main; all ten upstream workflows are 
untouched and still run. The module adds its own `ontogeny-ci.yml` alongside 
them.



##########
third_party/pyproject.toml:
##########
@@ -0,0 +1,207 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[project]
+name = "hugegraph-ai"
+version = "1.7.0"

Review Comment:
   Obsolete: `third_party/pyproject.toml` no longer exists (the module is at 
`ontogeny/` with its own pyproject as a self-contained unit; the root workspace 
is untouched).



##########
.gitignore:
##########
@@ -1,221 +1,35 @@
-# User-specific files
-**/logs/*.log*
-*.faiss
-*.pkl
-out/production/
-
-# Local PR review scratch artifacts
-.workflow/pr*-review/
-.workflow/code-scan/
-
-# Byte-compiled / optimized / DLL files
+# ---- runtimes & caches ----
+.venv/
+node_modules/
+.ontogeny-*/
+# legacy local state dirs from the kina era
+.kina-*/
+.oo-verify/
 __pycache__/
-*.py[cod]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-share/python-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
-MANIFEST
-
-# PyInstaller
-#  Usually these files are written by a python script from a template
-#  before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-# Exception: allow .spec/ directory to be tracked
-!.spec/
-!.spec/**
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
+*.pyc
 .pytest_cache/
-cover/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
+.ruff_cache/
+.DS_Store
+web/node_modules/
+web/dist/
+web/.e2e-port
+
+# ---- local state (databases, logs, pid/port files) ----
+*.db
 *.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-.pybuilder/
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pyenv
-#   For a library or package, you might want to ignore these files since the 
code is
-#   intended to run in multiple environments; otherwise, check them in:
-# .python-version
-
-# pipenv
-#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in 
version control.
-#   However, in case of collaboration, if having platform-specific 
dependencies or dependencies
-#   having no cross-platform support, pipenv may install dependencies that 
don't work, or not
-#   install all needed dependencies.
-#Pipfile.lock
-
-# poetry
-#   Similar to Pipfile.lock, it is generally recommended to include 
poetry.lock in version control.
-#   This is especially recommended for binary packages to ensure 
reproducibility, and is more
-#   commonly ignored for libraries.
-#   
https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
-#poetry.lock
-
-# pdm
-#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock 
in version control.
-#pdm.lock
-#   pdm stores project-wide configurations in .pdm.toml, but it is recommended 
to not include it
-#   in version control.
-#   https://pdm.fming.dev/#use-with-ide
-.pdm.toml
+*.pid
 
-# uv
-#   uv.lock contains resolved dependencies and is generally recommended to 
include in version control
-#   for reproducible builds, but can be excluded if you prefer to resolve 
dependencies fresh
-uv.lock
-# Local UV configuration file (for mirror settings)
-uv.toml
+# ---- internal design docs: local-only, never published ----
+.docs/
 
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow and 
github.com/pdm-project/pdm
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# prompt config
-config_prompt.yaml*
-
-# AI-IDE prompt files (generated from AGENTS.md)
-
-# Claude Projects
-CLAUDE.md
-CLAUDE_*.md
-
-# Gemini/Google
-GEMINI.md
-GEMINI_*.md
-gemini.md
-gemini_*.md
-
-# GitHub Copilot / Microsoft
-copilot-instructions.md
-.copilot-instructions.md
-
-# Cursor IDE
-cursor-instructions.md
-.cursor-instructions.md
-cursor.md
-
-# Windsurf/Codeium
-windsurf.md
-windsurf-instructions.md
-codeium.md
-codeium-instructions.md
-
-# Other AI coding assistants
-.ai-instructions.md
-*.ai-prompt.md
-
-# Environments
-.env
-.env.bak
-.venv
-.vscode
-.cursor
-env/
-venv/
-ENV/
-env.bak/
-venv.bak/
-
-# Spyder project settings
-.spyderproject
-.spyproject
-
-# Rope project settings
-.ropeproject
-
-# mkdocs documentation
-/site
-
-# mypy
-.mypy_cache/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
-
-# pytype static type analyzer
-.pytype/
+# ---- editor/tooling ----
+.idea/
+.vscode/
 
-# Cython debug symbols
-cython_debug/
+# ---- local tooling config (ZCode) ----
+.zcodeignore
 
-# PyCharm
-#  JetBrains specific template is maintained in a separate JetBrains.gitignore 
that can
-#  be found at 
https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
-#  and can be added to the global gitignore or merged into this file.  For a 
more nuclear
-#  option (not recommended) you can uncomment the following to ignore the 
entire idea folder.
-.idea/
-*.DS_Store
-*.out
-*.zip
-*.tar.gz
+# ---- third_party (vendored hugegraph-ai) build/test artifacts ----
+third_party/**/*.faiss

Review Comment:
   Obsolete: the upstream .gitignore is preserved verbatim; the module's 
runtime ignores are appended at the end (commit b8a9965). No upstream entries 
were dropped.



##########
ontogeny/server/ontogeny/auth/service.py:
##########
@@ -0,0 +1,270 @@
+# Copyright 2026 Apache HugeGraph Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Accounts, sessions and the administrator gate.
+
+One service over two tables (see ``models.py``). It owns:
+
+- **login/logout** — verify a password, mint a bearer token, keep only its
+  digest, and resolve a token back into the principal dict the engine consumes;
+- **account administration** — create/update/disable/delete users, reset
+  passwords, grant or revoke roles;
+- **bootstrap** — make sure an administrator exists so a fresh deployment is
+  reachable at all.
+
+Session lifetime is deliberately short and sliding-free: an expired session is
+re-authenticated, not silently extended.
+"""
+from __future__ import annotations
+
+import datetime as _dt
+import logging
+import os
+from typing import Any
+
+from sqlalchemy import delete, func, select
+
+from ..errors import AuthenticationError, DSLValidationError, NotFoundError
+from .models import SessionRow, UserRow
+from .passwords import hash_password, new_token, token_fingerprint, 
verify_password
+
+log = logging.getLogger("ontogeny.auth")
+
+SESSION_TTL = _dt.timedelta(hours=12)
+MIN_PASSWORD = 8
+
+
+class AuthService:
+    def __init__(self, sc) -> None:
+        self.sc = sc
+
+    # ------------------------------------------------------------------ users
+
+    async def _user(self, session, username: str) -> UserRow | None:
+        return (await session.execute(
+            select(UserRow).where(UserRow.username == username)
+        )).scalar_one_or_none()
+
+    @staticmethod
+    def _view(row: UserRow) -> dict[str, Any]:
+        return {
+            "id": row.id,
+            "username": row.username,
+            "display": row.display or row.username,
+            "roles": list(row.roles or []),
+            "site": row.site,
+            "markings": list(row.markings or []),
+            "status": row.status,
+            "is_admin": bool(row.is_admin),
+            "created_at": row.created_at.isoformat() if row.created_at else 
None,
+            "last_login_at": row.last_login_at.isoformat() if 
row.last_login_at else None,
+        }
+
+    async def list_users(self) -> list[dict[str, Any]]:
+        async with self.sc.sessionmaker() as session:
+            rows = (await 
session.execute(select(UserRow).order_by(UserRow.username))).scalars().all()
+            return [self._view(r) for r in rows]
+
+    async def create_user(self, *, username: str, password: str, display: str 
= "",
+                          roles: list[str] | None = None, site: str | None = 
None,
+                          markings: list[str] | None = None, is_admin: bool = 
False) -> dict[str, Any]:
+        username = (username or "").strip()
+        if not username:
+            raise DSLValidationError("username is required")
+        self._check_password(password)
+        async with self.sc.sessionmaker() as session:
+            if await self._user(session, username):
+                raise DSLValidationError(f"user {username!r} already exists")
+            row = UserRow(
+                username=username, display=display or username,
+                password_hash=hash_password(password),
+                roles=sorted(set(roles or [])), site=site or None,
+                markings=sorted(set(markings or [])), is_admin=bool(is_admin),
+                status="active",
+            )
+            session.add(row)
+            await session.commit()
+            await session.refresh(row)
+            return self._view(row)
+
+    async def update_user(self, username: str, patch: dict[str, Any]) -> 
dict[str, Any]:
+        async with self.sc.sessionmaker() as session:
+            row = await self._user(session, username)
+            if row is None:
+                raise NotFoundError(f"user {username!r} not found")
+            if "display" in patch and patch["display"] is not None:
+                row.display = str(patch["display"])
+            if patch.get("roles") is not None:
+                row.roles = sorted({str(r) for r in patch["roles"]})
+            if "site" in patch:
+                row.site = patch["site"] or None
+            if patch.get("markings") is not None:
+                row.markings = sorted({str(m) for m in patch["markings"]})
+            if patch.get("status"):
+                if patch["status"] not in ("active", "disabled"):
+                    raise DSLValidationError("status must be active or 
disabled")
+                row.status = patch["status"]
+            if patch.get("is_admin") is not None:
+                row.is_admin = bool(patch["is_admin"])
+            if patch.get("password"):
+                self._check_password(patch["password"])
+                row.password_hash = hash_password(patch["password"])
+                # a password change invalidates every existing session for that
+                # account: that is the whole point of changing it
+                await 
session.execute(delete(SessionRow).where(SessionRow.user_id == row.id))
+            await session.commit()
+            await session.refresh(row)
+            return self._view(row)
+
+    async def delete_user(self, username: str) -> dict[str, Any]:
+        async with self.sc.sessionmaker() as session:
+            row = await self._user(session, username)
+            if row is None:
+                raise NotFoundError(f"user {username!r} not found")
+            # Administrator accounts are platform-owned: never deletable, even
+            # when another administrator exists. This also protects the
+            # bootstrap admin from someone locking themselves out mid-setup.
+            if row.is_admin:
+                raise DSLValidationError("administrator accounts cannot be 
deleted (including the last administrator)")
+            await session.execute(delete(SessionRow).where(SessionRow.user_id 
== row.id))
+            await session.delete(row)
+            await session.commit()
+            return {"deleted": username}
+
+    @staticmethod
+    def _check_password(password: str) -> None:
+        if not password or len(password) < MIN_PASSWORD:
+            raise DSLValidationError(f"password must be at least 
{MIN_PASSWORD} characters")
+
+    # --------------------------------------------------------------- sessions
+
+    async def login(self, username: str, password: str) -> tuple[str, 
dict[str, Any]]:
+        """Verify credentials and mint a session. Returns (token, 
principal)."""
+        async with self.sc.sessionmaker() as session:
+            row = await self._user(session, (username or "").strip())
+            # same message either way: which half was wrong is not the caller's
+            # business, and a disabled account must not be distinguishable
+            if row is None or not verify_password(password, row.password_hash):
+                raise AuthenticationError("invalid username or password")
+            if row.status != "active":
+                raise AuthenticationError("this account is disabled")
+            token = new_token()
+            session.add(SessionRow(
+                token_hash=token_fingerprint(token),
+                user_id=row.id,
+                expires_at=_dt.datetime.now(_dt.timezone.utc) + SESSION_TTL,
+            ))
+            row.last_login_at = _dt.datetime.now(_dt.timezone.utc)
+            await session.commit()
+            return token, row.principal()
+
+    async def resolve(self, token: str) -> dict[str, Any] | None:
+        """A bearer token -> principal, or None when 
unknown/expired/disabled."""
+        if not token:
+            return None
+        now = _dt.datetime.now(_dt.timezone.utc)
+        async with self.sc.sessionmaker() as session:
+            sess = (await session.execute(
+                select(SessionRow).where(SessionRow.token_hash == 
token_fingerprint(token))
+            )).scalar_one_or_none()
+            if sess is None:
+                return None
+            expires = sess.expires_at
+            if expires is not None and expires.tzinfo is None:
+                expires = expires.replace(tzinfo=_dt.timezone.utc)
+            if expires is None or expires < now:
+                await session.delete(sess)
+                await session.commit()
+                return None
+            user = (await session.execute(
+                select(UserRow).where(UserRow.id == sess.user_id)
+            )).scalar_one_or_none()
+            if user is None or user.status != "active":
+                return None
+            sess.last_seen = now
+            await session.commit()
+            return user.principal()
+
+    async def logout(self, token: str) -> None:
+        if not token:
+            return
+        async with self.sc.sessionmaker() as session:
+            await session.execute(
+                delete(SessionRow).where(SessionRow.token_hash == 
token_fingerprint(token))
+            )
+            await session.commit()
+
+    async def list_sessions(self, username: str | None = None) -> 
list[dict[str, Any]]:
+        async with self.sc.sessionmaker() as session:
+            stmt = 
select(SessionRow).order_by(SessionRow.created_at.desc()).limit(200)
+            if username:
+                user = await self._user(session, username)
+                if user is None:
+                    return []
+                stmt = stmt.where(SessionRow.user_id == user.id)
+            rows = (await session.execute(stmt)).scalars().all()
+            users = {
+                u.id: u.username
+                for u in (await 
session.execute(select(UserRow))).scalars().all()
+            }
+            return [{
+                "token": r.token_hash[:12],
+                "user": users.get(r.user_id, "?"),
+                "created_at": r.created_at.isoformat() if r.created_at else 
None,
+                "expires_at": r.expires_at.isoformat() if r.expires_at else 
None,
+                "last_seen": r.last_seen.isoformat() if r.last_seen else None,
+            } for r in rows]
+
+    # -------------------------------------------------------------- bootstrap
+
+    async def ensure_admin(self) -> dict[str, Any] | None:
+        """Make sure at least one active administrator exists.
+
+        On a fresh database this creates the bootstrap account. The username
+        defaults to ``admin``; the initial password defaults to the fixed
+        bootstrap value ``ontogeny@2026`` unless ``ONTOGENY_ADMIN_PASSWORD`` 
overrides it.
+        """
+        async with self.sc.sessionmaker() as session:
+            existing = (await session.execute(
+                
select(func.count()).select_from(UserRow).where(UserRow.is_admin.is_(True))
+            )).scalar_one()
+            if existing:
+                return None
+
+        username = os.environ.get("ONTOGENY_ADMIN_USER", "admin")
+        password = os.environ.get("ONTOGENY_ADMIN_PASSWORD") or "ontogeny@2026"

Review Comment:
   Fixed (commit 9cfda13): when `ONTOGENY_ADMIN_PASSWORD` is unset, a random 
password is generated and printed once to stderr at first boot. No fixed 
published default.



##########
ontogeny/server/ontogeny/api/routes_objects.py:
##########
@@ -0,0 +1,305 @@
+# Copyright 2026 Apache HugeGraph Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Read/model surfaces: meta, objects, actions, functions, graph."""
+from __future__ import annotations
+
+import os
+
+from fastapi import APIRouter, Depends, HTTPException
+
+from ..errors import OOError
+from ..service import ServiceContext
+from .deps import get_principal, get_sc, require_admin
+from .schemas import (ActionBody, AggregateBody, ExploreBody, FunctionBody,
+                      FunctionSourceBody, FunctionTestBody, QueryBody, 
TraverseBody)
+
+
+def register(r: APIRouter, sc: ServiceContext) -> None:
+    @r.get("/meta/ontology")
+    async def meta(sc: ServiceContext = Depends(get_sc)):
+        return sc.compiled.to_meta()
+
+    @r.get("/meta/storage")
+    async def meta_storage(sc: ServiceContext = Depends(get_sc)):
+        """Where materialized object data physically lives: the platform's own
+        database (kind + file/DSN, credentials redacted), each object's
+        ``ontogeny_obj_<type>`` table, its backing store / source table / sync
+        strategy, and a best-effort live row count. This is what lets the
+        console say exactly where the data landed, next to every
+        "materialized" label."""
+        summary = sc.storage_summary()
+        # HugeGraph is a derived, optional projection layer — reported as its
+        # own "current storage" line when a projection is declared AND the
+        # graph extension/endpoint are actually wired (otherwise SQL-only).
+        first_prj = next(iter(sc.compiled.projections.values()), None) if 
sc.compiled else None
+        summary["graph"] = {
+            "declared": first_prj is not None,
+            "wired": sc.projection_worker is not None,
+            "engine": (first_prj.spec.engine if first_prj is not None else 
None),
+            "graph": (first_prj.spec.graph if first_prj is not None else None),
+            "endpoint_configured": bool(sc.settings.env.get("HUGEGRAPH_URL") 
or os.environ.get("HUGEGRAPH_URL")),
+            "endpoint": (sc._graph_endpoint() or None),
+            # WHY the graph layer is not wired (None = it is): lets the console
+            # say "pick HugeGraph as provider" / "fix the URL" / "load the
+            # extension" instead of a generic "not configured".
+            "provider": sc._storage_provider(),
+            "blocked_by": sc.projection_blocked_by,
+        }
+        if sc.engine is not None:
+            from sqlalchemy import text
+
+            for name, info in summary["objects"].items():
+                try:
+                    async with sc.sessionmaker() as s:
+                        row = await s.execute(text(f'SELECT COUNT(*) FROM 
"{info["table"]}"'))
+                        info["rows"] = int(row.scalar_one())
+                except Exception:  # noqa: BLE001 -- table may not exist yet 
(never synced)
+                    info["rows"] = None
+        return summary
+
+    @r.get("/meta/lineage")
+    async def lineage(sc: ServiceContext = Depends(get_sc)):
+        async with sc.sessionmaker() as s:
+            return await sc.registry.lineage(s)
+
+    # ---- objects ----------------------------------------------------------
+
+    @r.post("/objects/{object_type}/query")
+    async def query_objects(object_type: str, body: QueryBody,
+                            sc: ServiceContext = Depends(get_sc), principal: 
dict = Depends(get_principal)):
+        import time
+
+        started = time.perf_counter()
+        try:
+            async with sc.sessionmaker() as s:
+                out = await sc.query.query(
+                    s, object_type, principal, filt=body.filter, 
sort=body.sort,
+                    limit=body.limit, offset=body.offset, links=body.links,
+                )
+                await sc.telemetry.record_query(
+                    s, object_type, principal, body.filter, out["latency_ms"], 
out["total"],
+                )
+                await s.commit()
+            return out
+        except OOError:
+            # failed queries are fitness signal too (unmapped fields, bad 
shapes)
+            async with sc.sessionmaker() as s:
+                await sc.telemetry.record_query(
+                    s, object_type, principal, body.filter,
+                    (time.perf_counter() - started) * 1000, 0, error=True,
+                )
+                await s.commit()
+            raise
+
+    @r.get("/objects/{object_type}/{object_id}")
+    async def get_object(object_type: str, object_id: str,
+                         sc: ServiceContext = Depends(get_sc), principal: dict 
= Depends(get_principal)):
+        async with sc.sessionmaker() as s:
+            return await sc.query.get(s, object_type, object_id, principal)
+
+    @r.get("/objects/{object_type}/{object_id}/links/{link_name}")
+    async def get_links(object_type: str, object_id: str, link_name: str,
+                        sc: ServiceContext = Depends(get_sc), principal: dict 
= Depends(get_principal)):
+        async with sc.sessionmaker() as s:
+            return await sc.query.links(s, object_type, object_id, link_name, 
principal)
+
+    @r.post("/objects/{object_type}/aggregate")
+    async def aggregate(object_type: str, body: AggregateBody,
+                        sc: ServiceContext = Depends(get_sc), principal: dict 
= Depends(get_principal)):
+        async with sc.sessionmaker() as s:
+            return {"value": await sc.query.aggregate(s, object_type, 
principal, body.fn, body.field, filt=body.filter)}
+
+    @r.get("/objects/{object_type}/{object_id}/history")
+    async def history(object_type: str, object_id: str,
+                      sc: ServiceContext = Depends(get_sc), principal: dict = 
Depends(get_principal)):
+        async with sc.sessionmaker() as s:
+            rows = await sc.repo.history(s, object_type, object_id)
+        # Historical versions are reads like any other: the masking the query
+        # path applies used to be skipped here, so a marked property's value
+        # leaked verbatim through its own version trail.
+        versions = [sc.policy.mask(object_type, row, principal) for row in 
rows]
+        return {"versions": [
+            {k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in 
row.items()} for row in versions
+        ]}
+
+    # ---- actions ----------------------------------------------------------
+
+    @r.post("/actions/{action_name}/validate")
+    async def validate_action(action_name: str, body: ActionBody,
+                              sc: ServiceContext = Depends(get_sc), principal: 
dict = Depends(get_principal)):
+        async with sc.sessionmaker() as s:
+            return await sc.runtime.validate(s, action_name, principal, 
body.parameters, body.target_id)
+
+    @r.post("/actions/{action_name}/execute")
+    async def execute_action(action_name: str, body: ActionBody,
+                             sc: ServiceContext = Depends(get_sc), principal: 
dict = Depends(get_principal)):
+        import time
+
+        started = time.perf_counter()
+        from ..errors import PolicyDeniedError, RuleRejectedError
+
+        async with sc.sessionmaker() as s:
+            try:
+                rev = await sc.runtime.execute(
+                    s, action_name, principal, body.parameters, body.target_id,
+                    expected_revision=body.expected_revision, 
idempotency_key=body.idempotency_key,
+                )
+                await s.commit()
+                await sc.telemetry.record_action(
+                    s, action_name, rev.outcome, 
duration_ms=(time.perf_counter() - started) * 1000,
+                )
+                await s.commit()
+                return {"revision_id": rev.id, "outcome": rev.outcome, 
"object_id": rev.object_id,
+                        "after": rev.after, "message": rev.message}
+            except RuleRejectedError:
+                # the runtime already committed the rejection revision; record 
the
+                # outcome for the evolution loop and surface the error. The two
+                # rejection classes are different findings downstream (the
+                # rule-reject detector counts `rejected_rule` specifically), 
and
+                # both were collapsed into "rejected" here, which starved it.
+                await sc.telemetry.record_action(s, action_name, 
"rejected_rule", duration_ms=(time.perf_counter() - started) * 1000)
+                await s.commit()
+                raise
+            except PolicyDeniedError:
+                await sc.telemetry.record_action(s, action_name, 
"denied_policy", duration_ms=(time.perf_counter() - started) * 1000)
+                await s.commit()
+                raise
+
+    # ---- functions --------------------------------------------------------
+
+    @r.post("/functions/{name}/invoke")

Review Comment:
   Fixed (commit 9cfda13): `POST /functions/{name}/invoke` now requires an 
authenticated principal (`Depends(require_authenticated)`).



##########
server/ontogeny/api/routes_objects.py:
##########
@@ -0,0 +1,305 @@
+# Copyright 2026 Apache HugeGraph Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Read/model surfaces: meta, objects, actions, functions, graph."""
+from __future__ import annotations
+
+import os
+
+from fastapi import APIRouter, Depends, HTTPException
+
+from ..errors import OOError
+from ..service import ServiceContext
+from .deps import get_principal, get_sc, require_admin
+from .schemas import (ActionBody, AggregateBody, ExploreBody, FunctionBody,
+                      FunctionSourceBody, FunctionTestBody, QueryBody, 
TraverseBody)
+
+
+def register(r: APIRouter, sc: ServiceContext) -> None:
+    @r.get("/meta/ontology")
+    async def meta(sc: ServiceContext = Depends(get_sc)):
+        return sc.compiled.to_meta()
+
+    @r.get("/meta/storage")
+    async def meta_storage(sc: ServiceContext = Depends(get_sc)):
+        """Where materialized object data physically lives: the platform's own
+        database (kind + file/DSN, credentials redacted), each object's
+        ``ontogeny_obj_<type>`` table, its backing store / source table / sync
+        strategy, and a best-effort live row count. This is what lets the
+        console say exactly where the data landed, next to every
+        "materialized" label."""
+        summary = sc.storage_summary()
+        # HugeGraph is a derived, optional projection layer — reported as its
+        # own "current storage" line when a projection is declared AND the
+        # graph extension/endpoint are actually wired (otherwise SQL-only).
+        first_prj = next(iter(sc.compiled.projections.values()), None) if 
sc.compiled else None
+        summary["graph"] = {
+            "declared": first_prj is not None,
+            "wired": sc.projection_worker is not None,
+            "engine": (first_prj.spec.engine if first_prj is not None else 
None),
+            "graph": (first_prj.spec.graph if first_prj is not None else None),
+            "endpoint_configured": bool(sc.settings.env.get("HUGEGRAPH_URL") 
or os.environ.get("HUGEGRAPH_URL")),
+            "endpoint": (sc._graph_endpoint() or None),
+            # WHY the graph layer is not wired (None = it is): lets the console
+            # say "pick HugeGraph as provider" / "fix the URL" / "load the
+            # extension" instead of a generic "not configured".
+            "provider": sc._storage_provider(),
+            "blocked_by": sc.projection_blocked_by,
+        }
+        if sc.engine is not None:
+            from sqlalchemy import text
+
+            for name, info in summary["objects"].items():
+                try:
+                    async with sc.sessionmaker() as s:
+                        row = await s.execute(text(f'SELECT COUNT(*) FROM 
"{info["table"]}"'))
+                        info["rows"] = int(row.scalar_one())
+                except Exception:  # noqa: BLE001 -- table may not exist yet 
(never synced)
+                    info["rows"] = None
+        return summary
+
+    @r.get("/meta/lineage")
+    async def lineage(sc: ServiceContext = Depends(get_sc)):
+        async with sc.sessionmaker() as s:
+            return await sc.registry.lineage(s)
+
+    # ---- objects ----------------------------------------------------------
+
+    @r.post("/objects/{object_type}/query")
+    async def query_objects(object_type: str, body: QueryBody,
+                            sc: ServiceContext = Depends(get_sc), principal: 
dict = Depends(get_principal)):
+        import time
+
+        started = time.perf_counter()
+        try:
+            async with sc.sessionmaker() as s:
+                out = await sc.query.query(
+                    s, object_type, principal, filt=body.filter, 
sort=body.sort,
+                    limit=body.limit, offset=body.offset, links=body.links,
+                )
+                await sc.telemetry.record_query(
+                    s, object_type, principal, body.filter, out["latency_ms"], 
out["total"],
+                )
+                await s.commit()
+            return out
+        except OOError:
+            # failed queries are fitness signal too (unmapped fields, bad 
shapes)
+            async with sc.sessionmaker() as s:
+                await sc.telemetry.record_query(
+                    s, object_type, principal, body.filter,
+                    (time.perf_counter() - started) * 1000, 0, error=True,
+                )
+                await s.commit()
+            raise
+
+    @r.get("/objects/{object_type}/{object_id}")
+    async def get_object(object_type: str, object_id: str,
+                         sc: ServiceContext = Depends(get_sc), principal: dict 
= Depends(get_principal)):
+        async with sc.sessionmaker() as s:
+            return await sc.query.get(s, object_type, object_id, principal)
+
+    @r.get("/objects/{object_type}/{object_id}/links/{link_name}")
+    async def get_links(object_type: str, object_id: str, link_name: str,
+                        sc: ServiceContext = Depends(get_sc), principal: dict 
= Depends(get_principal)):
+        async with sc.sessionmaker() as s:
+            return await sc.query.links(s, object_type, object_id, link_name, 
principal)
+
+    @r.post("/objects/{object_type}/aggregate")
+    async def aggregate(object_type: str, body: AggregateBody,
+                        sc: ServiceContext = Depends(get_sc), principal: dict 
= Depends(get_principal)):
+        async with sc.sessionmaker() as s:
+            return {"value": await sc.query.aggregate(s, object_type, 
principal, body.fn, body.field, filt=body.filter)}
+
+    @r.get("/objects/{object_type}/{object_id}/history")
+    async def history(object_type: str, object_id: str,
+                      sc: ServiceContext = Depends(get_sc), principal: dict = 
Depends(get_principal)):
+        async with sc.sessionmaker() as s:
+            rows = await sc.repo.history(s, object_type, object_id)
+        # Historical versions are reads like any other: the masking the query
+        # path applies used to be skipped here, so a marked property's value
+        # leaked verbatim through its own version trail.
+        versions = [sc.policy.mask(object_type, row, principal) for row in 
rows]
+        return {"versions": [
+            {k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in 
row.items()} for row in versions
+        ]}
+
+    # ---- actions ----------------------------------------------------------
+
+    @r.post("/actions/{action_name}/validate")
+    async def validate_action(action_name: str, body: ActionBody,
+                              sc: ServiceContext = Depends(get_sc), principal: 
dict = Depends(get_principal)):
+        async with sc.sessionmaker() as s:
+            return await sc.runtime.validate(s, action_name, principal, 
body.parameters, body.target_id)
+
+    @r.post("/actions/{action_name}/execute")
+    async def execute_action(action_name: str, body: ActionBody,
+                             sc: ServiceContext = Depends(get_sc), principal: 
dict = Depends(get_principal)):
+        import time
+
+        started = time.perf_counter()
+        from ..errors import PolicyDeniedError, RuleRejectedError
+
+        async with sc.sessionmaker() as s:
+            try:
+                rev = await sc.runtime.execute(
+                    s, action_name, principal, body.parameters, body.target_id,
+                    expected_revision=body.expected_revision, 
idempotency_key=body.idempotency_key,
+                )
+                await s.commit()
+                await sc.telemetry.record_action(
+                    s, action_name, rev.outcome, 
duration_ms=(time.perf_counter() - started) * 1000,
+                )
+                await s.commit()
+                return {"revision_id": rev.id, "outcome": rev.outcome, 
"object_id": rev.object_id,
+                        "after": rev.after, "message": rev.message}

Review Comment:
   Fixed (commit 9cfda13): the execute response now masks `rev.after` per the 
caller's principal before returning.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to