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


##########
server/ontogeny/functions/sandbox.py:
##########
@@ -0,0 +1,320 @@
+# 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.
+"""Function sandbox: subprocess isolation + capability-scoped query SDK.
+
+The child never touches the database: every ``ontogeny.query`` travels over 
stdio
+JSON-RPC to this parent, which enforces the declared capabilities
+(``read-objects: [...]``) before executing read-only queries. Hard resource
+limits (CPU / address space / file descriptors) are applied at spawn time on
+POSIX; a wall-clock timeout kills runaway processes.
+"""
+from __future__ import annotations
+
+import asyncio
+import datetime as _dt
+import json
+import os
+import sys
+from pathlib import Path
+from typing import Any
+
+from ..errors import SandboxError
+from ..stores.repo import ObjectRepository
+
+
+def _limits() -> None:  # pragma: no cover -- preexec in the child process
+    try:
+        import resource
+
+        resource.setrlimit(resource.RLIMIT_CPU, (10, 10))
+        try:
+            resource.setrlimit(resource.RLIMIT_AS, (512 << 20, 512 << 20))
+        except (ValueError, OSError):
+            pass  # macOS disallows RLIMIT_AS in some configurations
+        resource.setrlimit(resource.RLIMIT_NOFILE, (64, 64))
+    except Exception:
+        pass
+
+
+def _jsonable(value: Any) -> Any:
+    if isinstance(value, (_dt.datetime, _dt.date)):
+        return value.isoformat()
+    if isinstance(value, dict):
+        return {k: _jsonable(v) for k, v in value.items()}
+    if isinstance(value, (list, tuple)):
+        return [_jsonable(v) for v in value]
+    return value
+
+
+def function_source_path(compiled, function_resource) -> Path:
+    """Where this function's code must live: 
``<package_root>/functions/<file>``.
+
+    Resolution only — it does NOT require the file to exist. Callers that need
+    to run the function (the sandbox) check that themselves and can explain the
+    failure; callers that need to *report* the situation (the console showing
+    "what code runs here?") need the path even when it is missing, which is
+    exactly the case a canvas-created function starts in.
+    """
+    file, _, func = function_resource.spec.entry.partition(":")
+    if not func:
+        raise SandboxError(f"entry must be 'file.py:function', got 
{function_resource.spec.entry!r}")
+    return Path(compiled.package_root) / "functions" / file
+
+
+def _llm_budget(cfg: dict | None) -> int:
+    """How many model calls a declaration permits.
+
+    ``max-calls: 0`` means ZERO, not "unset": the declaration is authoritative,
+    and a falsy-zero bug here would grant MORE calls than declared (10), which 
is
+    the one direction a budget must never fail in.
+    """
+    if not cfg or cfg.get("max-calls") is None:
+        return 10
+    return max(0, int(cfg["max-calls"]))
+
+
+def sandbox_env(server_dir: str) -> dict[str, str]:
+    """Environment allowlist, not inheritance: the child used to receive the
+    WHOLE process env (DB DSN, HUGEGRAPH credentials, LLM API keys). A
+    sandboxed function is package code, but "resource fence, not security
+    boundary" is a claim we only get to make if secrets are not handed over
+    by default. Operators can widen the list explicitly via
+    ``ONTOGENY_SANDBOX_ENV_PASSTHROUGH`` (comma-separated names)."""
+    passthrough = {"PATH", "LANG", "LC_ALL", "LC_CTYPE", "HOME", "TMPDIR",
+                   "SYSTEMROOT", "COMSPEC", "PYTHONIOENCODING", "PYTHONUTF8"}
+    for var in os.environ.get("ONTOGENY_SANDBOX_ENV_PASSTHROUGH", 
"").split(","):
+        if var.strip():
+            passthrough.add(var.strip())
+    env = {k: v for k, v in os.environ.items() if k in passthrough}
+    env["PYTHONPATH"] = os.pathsep.join(
+        p for p in (server_dir, env.get("PYTHONPATH", "")) if p
+    )
+    # never drop __pycache__ into the user's ontology package (it is a git
+    # working tree: bytecode would show up as untracked noise and would
+    # change the package's content hash for anything that digests the tree)
+    env["PYTHONDONTWRITEBYTECODE"] = "1"
+    return env
+
+
+class FunctionSandbox:
+    def __init__(self, compiled, repo: ObjectRepository, *, timeout_s: float = 
10.0,
+                 llm=None, llm_call_timeout_s: float = 60.0) -> None:
+        self.compiled = compiled
+        self.repo = repo
+        self.timeout_s = timeout_s
+        self.llm = llm  # platform LLM gateway (ontogeny.llm.ChatClient 
contract); None = not configured
+        # Wall clock a single ``ontogeny.llm`` round trip may take. The base 
budget
+        # above is deliberately tight -- it bounds local work on object rows --
+        # but a model round trip is not local work: a 27B model over the 
network
+        # routinely answers in 10s+, so a function that declares the capability
+        # would ALWAYS be killed by a 10s wall clock before its model reply 
came
+        # back. See ``_budget`` for how the two combine.
+        self.llm_call_timeout_s = llm_call_timeout_s
+
+    def _budget(self, function_resource) -> float:
+        """Wall clock allowed for one invocation of this function.
+
+        The declared capability buys the time it needs, in proportion to its 
own
+        declared call budget: a function that may make N model calls gets the
+        local budget plus N call timeouts. Everything is still bounded -- the
+        extra time is never open-ended, and a function that declares no ``llm``
+        keeps the original tight limit.
+        """
+        cfg = self._llm_capability(function_resource)
+        if cfg is None:
+            return self.timeout_s
+        calls = _llm_budget(cfg)
+        return self.timeout_s + max(1, calls) * self.llm_call_timeout_s
+
+    def _read_objects_capability(self, function_resource) -> set[str]:
+        allowed: set[str] = set()
+        for cap in function_resource.spec.capabilities:
+            data = cap.model_dump(exclude_none=True)
+            if "read-objects" in data:
+                allowed.update(data["read-objects"])
+        return allowed
+
+    def _llm_capability(self, function_resource) -> dict | None:
+        """Declared llm capability config: True -> {} / {model, max-calls, 
...}."""
+        for cap in function_resource.spec.capabilities:
+            data = cap.model_dump(exclude_none=True)
+            if "llm" in data:
+                cfg = data["llm"]
+                return cfg if isinstance(cfg, dict) else {}
+        return None
+
+    def _entry_path(self, function_resource) -> Path:
+        file, _, func = function_resource.spec.entry.partition(":")
+        if not func:
+            raise SandboxError(f"entry must be 'file.py:function', got 
{function_resource.spec.entry!r}")
+        root = Path(self.compiled.package_root)
+        path = function_source_path(self.compiled, function_resource)
+        if not path.is_file():
+            if not root.is_dir() or not (root / "ontology.yaml").is_file():
+                raise SandboxError(
+                    f"cannot locate function source {file!r}: the ontology 
snapshot was "
+                    f"loaded from the registry and has no package directory on 
disk. "
+                    f"Set ONTOGENY_PACKAGE_ROOT to the checked-out ontology 
package "
+                    f"(currently {self.compiled.package_root!r}).",
+                    details={"entry": function_resource.spec.entry, 
"package_root": str(root)},
+                )
+            raise SandboxError(f"function source not found: {path}")
+        return path
+
+    async def run(self, session, function_resource, params: dict[str, Any]) -> 
Any:
+        return await self._spawn(session, function_resource,
+                                 {"type": "invoke", "params": 
_jsonable(params)})
+
+    async def run_source(self, session, function_resource, source: str,
+                         params: dict[str, Any]) -> Any:
+        """One invocation of text straight from the editor, persisting nothing.
+
+        The console's "Test run" needs an answer for code that may never be
+        saved, so the buffer is written to a throwaway file OUTSIDE the package
+        (a stray file in ``functions/`` would change the package's content hash
+        and show up as git noise) and the child loads that instead. Everything
+        else — entry name, capabilities, budget — is still the declared
+        function's, so a test run proves exactly what a save would run.
+        """
+        import tempfile
+
+        with tempfile.TemporaryDirectory(prefix="ontogeny-fn-test-") as tmp:
+            path = Path(tmp) / "buffer.py"
+            path.write_text(source, encoding="utf-8")
+            return await self._spawn(session, function_resource,
+                                     {"type": "invoke", "params": 
_jsonable(params)},
+                                     path=path)
+
+    async def map_rows(self, session, function_resource, rows: list[dict[str, 
Any]],
+                       params: dict[str, Any] | None = None, *, object_param: 
str = "obj") -> list[Any]:
+        """Batch mode for the derivation worker: ONE subprocess evaluates the
+        function per row (the loop runs inside the child), values returned in
+        row order. Same capability enforcement as run()."""
+        return await self._spawn(session, function_resource, {
+            "type": "map", "rows": _jsonable(rows), "params": _jsonable(params 
or {}),
+            "object_param": object_param,
+        })
+
+    async def _spawn(self, session, function_resource, init_message: dict[str, 
Any],
+                     *, path: Path | None = None) -> Any:
+        entry = function_resource.spec.entry
+        path = path or self._entry_path(function_resource)
+        _, func = entry.split(":", 1)
+        allowed = self._read_objects_capability(function_resource)
+
+        server_dir = str(Path(__file__).resolve().parent.parent.parent)
+        env = sandbox_env(server_dir)
+
+        proc = await asyncio.create_subprocess_exec(
+            sys.executable, "-m", "ontogeny.functions.child", str(path), func,
+            stdin=asyncio.subprocess.PIPE,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.DEVNULL,
+            env=env,
+            preexec_fn=_limits if os.name == "posix" else None,
+        )
+        assert proc.stdin and proc.stdout
+
+        llm_cfg = self._llm_capability(function_resource)
+        llm_budget = _llm_budget(llm_cfg)
+        llm_used = 0
+
+        async def child_main() -> Any:
+            proc.stdin.write((json.dumps(init_message) + "\n").encode())
+            await proc.stdin.drain()
+            nonlocal llm_used
+            while True:
+                line = await proc.stdout.readline()
+                if not line:
+                    raise SandboxError("sandbox exited before returning a 
result")
+                msg = json.loads(line)

Review Comment:
   Fixed: the child process binds the protocol stream to the real stdout at 
startup and redirects user stdout to stderr; the parent wraps non-JSON lines as 
SandboxError. Commit 4e1e130.



##########
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:

Review Comment:
   Fixed: demoting/disabling the last active administrator is refused (count 
excluding the row itself); `ensure_admin` self-heals a demoted bootstrap 
account instead of crashing. Commit 4e1e130.



##########
server/ontogeny/api/schemas.py:
##########
@@ -0,0 +1,274 @@
+# 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.
+"""Request bodies for the REST surface. Module-level on purpose: FastAPI
+resolves handler annotations against the module namespace -- a model class
+nested inside a function silently degrades the parameter to a query param."""
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from pydantic import BaseModel
+
+# ------------------------------------------------------------------ schemas
+
+
+class QueryBody(BaseModel):
+    filter: Any = None
+    sort: Any = None
+    limit: int = 100

Review Comment:
   Fixed: `QueryBody.limit` is now `Field(100, ge=1, le=1000)`; 
`TraverseBody.max_depth` is bounded; the query service and broker sessionless 
search clamp limits as a belt-and-suspenders for the MCP surface. Commit 
4e1e130.



##########
server/ontogeny/projection/worker.py:
##########
@@ -0,0 +1,191 @@
+# 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.
+"""Projection worker: outbox consumer + full rebuild, engine-agnostic.
+
+The worker encodes the *policy* of graph projection (whitelists, event
+handling, link re-emission, backfill) against a narrow ``GraphStore``
+interface. Concrete stores live in ``extensions/`` (``graph-hugegraph``
+ships with the repo) and are injected via ``sc.graph_store_factory``; with no
+graph extension loaded the platform degrades to SQL-only by design.
+"""
+from __future__ import annotations
+
+import logging
+from typing import Any, Protocol
+
+from ..action.models import OutboxRow
+
+log = logging.getLogger("ontogeny.projection")
+
+
+class GraphStore(Protocol):
+    """Structural contract for a projected-graph backend.
+
+    Implemented by the graph extension's client; the worker and the preview
+    endpoints call exactly this surface, so a new graph engine is a new
+    extension rather than a core change.
+    """
+
+    async def ensure_graph(self) -> bool:
+        """Create this domain's graph on the server if it is absent. True = 
created.
+
+        HugeGraph does not create a graph on first write, so a build must ask 
for
+        it explicitly — the difference between "no graph yet" and "cannot 
build"
+        is exactly this call.
+        """
+        ...
+
+    async def ensure_schema(self, schema: dict[str, Any]) -> None: ...
+
+    async def exists(self) -> bool | None:
+        """Whether this graph exists on the server yet (``None`` = cannot 
tell).
+
+        The load-vs-build decision: an existing graph is *loaded* (its contents
+        are the answer, and rebuilding would throw them away), a missing one is
+        built. ``None`` must not be treated as "absent" — offering a 
destructive
+        build because a probe failed is worse than saying "unknown".
+        """
+        ...
+
+    async def clear(self) -> None:
+        """Drop every vertex and edge in the graph, keeping its schema.
+
+        A rebuild's first step. Without it a rebuild only ever *adds*: rows 
that
+        the previous package projected stay in the graph forever, so the graph
+        keeps answering with objects the platform no longer has.
+        """
+        ...
+
+    async def dialect(self) -> str: ...
+
+    async def labels(self) -> dict[str, list[dict[str, Any]]]: ...
+
+    async def counts(self) -> dict[str, dict[str, int]]: ...
+
+    async def sample_vertices(self, label: str, limit: int = 20) -> 
list[dict[str, Any]]: ...
+
+    async def sample_edges(self, label: str, limit: int = 20) -> 
list[dict[str, Any]]: ...
+
+    async def upsert_vertices(self, label: str, pk_prop: str, items: 
list[dict[str, Any]]) -> None: ...
+
+    async def upsert_edges(self, link_name: str, src_label: str, dst_label: 
str,
+                           pairs: list[tuple[str, str]]) -> None: ...
+
+    async def delete_vertex(self, label: str, vertex_id: str) -> None: ...
+
+
+class ProjectionWorker:
+    """Outbox consumer: object rows -> graph vertices/edges (eventual 
consistency).
+
+    Same-object events are processed strictly in outbox-id order; failures are
+    retried by cursor rewind rather than skipped (at-least-once, idempotent 
upserts).
+    """
+
+    def __init__(self, compiled, projection, client: GraphStore) -> None:
+        self.compiled = compiled
+        self.projection = projection
+        self.client = client
+
+    def _whitelist(self, object_type: str) -> tuple[list[str], str]:
+        cfg = self.projection.spec.include.objects[object_type]
+        obj = self.compiled.objects[object_type]
+        return cfg.properties, obj.spec.primaryKey[0]
+
+    async def project_object(self, repo, session, object_type: str, obj_id: 
str) -> None:
+        props_whitelist, pk = self._whitelist(object_type)
+        row = await repo.get(session, object_type, obj_id)
+        if row is None:
+            await self.client.delete_vertex(object_type, str(obj_id))
+            return
+        item = {p: row.get(p) for p in props_whitelist}
+        item[pk] = row.get(pk)
+        await self.client.upsert_vertices(object_type, pk, [item])
+
+    async def _link_pairs(self, repo, session, link_name: str,
+                          *, src_id: str | None = None,
+                          dst_id: str | None = None) -> list[tuple[str, str]]:
+        """Edges of a projected link, from whichever store backs its join.
+
+        Both join kinds must reach the graph: a join-table link is read from 
its
+        relation table, an FK link from the target-side table's foreign key
+        column. Returns every pair when neither side is given (full backfill).
+        """
+        from ..core.models import JoinTableJoin
+
+        lnk = self.compiled.links[link_name]
+        if isinstance(lnk.spec.join, JoinTableJoin):
+            return await repo.link_members(session, link_name, src_id=src_id, 
dst_id=dst_id)
+        if src_id is None and dst_id is None:
+            return await repo.fk_link_pairs(session, link_name)
+        if src_id is not None:
+            targets = await repo.fk_linked_ids(session, link_name, src_id, 
from_source=True)
+            return [(src_id, t) for t in targets]
+        sources = await repo.fk_linked_ids(session, link_name, dst_id, 
from_source=False)
+        return [(s, dst_id) for s in sources]
+
+    async def _reproject_links(self, repo, session, object_type: str, obj_id: 
str) -> None:
+        """Re-emit the edges this object participates in, either side of the 
link.
+
+        An FK link whose value *moved* leaves the previous edge behind (the old
+        value is not in the event); the projection is derived and rebuildable, 
so
+        a full rebuild is the consistency backstop for that case.
+        """
+        for link_name in self.projection.spec.include.links:
+            lnk = self.compiled.links[link_name]
+            pairs: list[tuple[str, str]] = []
+            if object_type == lnk.spec.source:
+                pairs += await self._link_pairs(repo, session, link_name, 
src_id=obj_id)
+            if object_type == lnk.spec.target:
+                pairs += await self._link_pairs(repo, session, link_name, 
dst_id=obj_id)
+            if pairs:
+                await self.client.upsert_edges(
+                    link_name, lnk.spec.source, lnk.spec.target, pairs,
+                )
+
+    async def handle_event(self, repo, session, event: OutboxRow) -> bool:
+        object_type = event.object_type
+        if object_type not in self.projection.spec.include.objects:
+            return True  # not projected: consumed silently
+        try:
+            if event.op == "archive":
+                await self.client.delete_vertex(object_type, 
str(event.object_id))
+                return True
+            await self.project_object(repo, session, object_type, 
str(event.object_id))
+            await self._reproject_links(repo, session, object_type, 
str(event.object_id))
+            return True
+        except Exception as exc:  # noqa: BLE001 -- worker must never crash 
the pipeline
+            log.warning("projection failed for %s/%s: %s", object_type, 
event.object_id, exc)
+            return False
+
+    async def rebuild(self, repo, session, sync_engine=None) -> dict[str, int]:
+        """Full backfill from the authoritative object tables 
(drop-and-rebuild)."""
+        counts = {"vertices": 0, "edges": 0}
+        for object_type in self.projection.spec.include.objects:
+            props_whitelist, pk = self._whitelist(object_type)
+            rows, total = await repo.query(session, object_type, limit=10000)

Review Comment:
   Fixed: rebuild now uses keyset pagination by PK (no row cap, no silent 
truncation). Commit 4e1e130.



##########
ontogeny/server/ontogeny/builder_ops.py:
##########
@@ -0,0 +1,235 @@
+# 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.
+"""Builder materialization: resource JSON -> YAML files -> validated publish.
+
+Used to be ~190 lines of file orchestration inside the HTTP handler; it is a
+platform operation (the role manager writes managed Cedar through it too), so
+it lives at the service level where CLI/other shells can reach it. Raises
+``DSLValidationError`` instead of returning error responses."""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+
+async def builder_save(sc, resources: list[dict[str, Any]], *, deletes: 
list[str] | None = None,
+                       layout: dict[str, Any] | None = None, story: 
list[dict[str, Any]] | None = None) -> dict[str, Any]:
+    from .core import load_package, validate as _v
+    from .core.models import RESOURCE_BY_KIND
+    from .errors import DSLValidationError
+
+    body = _Body(resources=resources, deletes=deletes, layout=layout, 
story=story)
+
+    """Scenario Builder: accept resource JSON → write YAML to pkg dir → 
publish.
+
+    The package_root must be writable (a demo data-dir copy or a git work
+    tree). The builder sends only the resources it touched (partial save is
+    the data-loss-safe default: untouched YAML on disk is never rewritten
+    from lossy meta), plus explicit ``deletes`` for removed resources.
+    """
+    import yaml as _yaml
+
+    if sc.package_root is None:
+        raise DSLValidationError("ONTOGENY_PACKAGE_ROOT not configured")
+    pkg_dir = Path(sc.package_root)
+    if not pkg_dir.is_dir():
+        raise DSLValidationError(f"package dir {pkg_dir} does not exist")
+
+
+
+    # Inline data-source declarations: the console's data-mount editor puts
+    # the source INTO backing.store_spec ({kind, connection}) — a bare store
+    # name would be a placeholder, not a source. Extract it before the
+    # pydantic parse (BackingSpec has no such field) and materialize it as
+    # stores/<store>.yaml right after (same pattern as PolicySet cedar
+    # sidecars). The object YAML on disk stays clean.
+    store_sidecars: list[tuple[str, str, str]] = []
+    for raw in resources:
+        if raw.get("kind") != "ObjectType":
+            continue
+        backing = (raw.get("spec") or {}).get("backing") or {}
+        store_spec = backing.get("store_spec")
+        store_name = str(backing.get("store") or "").strip()
+        if isinstance(store_spec, dict) and store_name:
+            kind = str(store_spec.get("kind") or "").strip()
+            connection = str(store_spec.get("connection") or "").strip()
+            if kind and connection:
+                store_sidecars.append((store_name, kind, connection))

Review Comment:
   Fixed: the collected `store_sidecars` list is now written to 
`stores/<name>.yaml` (the dead second pass that re-read the already-parsed spec 
is removed). Commit 4e1e130.



-- 
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