Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"baseline_version": "2.7.39",
"classification_rationale": "L3-FORGE-PROJECT-DAG-READ-V1-20261001",
"component": "product",
"event_lineage": "L3-FORGE-PROJECT-DAG-READ-V1-20261001",
"expected_head": "35815de3c04e24c3a913a4f95cfe5af8c27dd768",
"operation_id": "l3-project-dag-read-20261001",
"policy_revision": "forge-bootstrap-release-cadence-v2",
"product": "forge",
"projection_paths": "product-version.json",
"release_class": "PATCH",
"requested_bump": "patch",
"requested_version": null,
"schema_version": "1",
"target_version": "2.7.40"
}
11 changes: 11 additions & 0 deletions docs/architecture/FORGE_PROJECT_DAG_READ_V1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Forge project-bound Mission/Action read projection V1

This is a bounded producer subset of `PRM-F-CONTRACT` and `PRM-F-PROJECTION` through the existing Forge Server `FH-HTTP` surface. It is not the complete project-loop capability or a new Project registration service. The current Forge Server binds at most one Engineering Platform project and repository through its existing versioned peer configuration. Both endpoints use the Server's instance-owned bearer credential and return JSON with `Cache-Control: no-store`.

- `GET /v1/projects` returns `project-roadmap-read/v1`, the exact Forge instance ID and either one configured EP project/repository binding or `UNCONFIGURED` with an empty list. The list is a binding readback, not an EP availability check or a project creation operation.
- `GET /v1/projects/{project_id}/roadmap` accepts only the configured project ID. It reads up to 256 actual persisted Forge Mission states within one SQLite read transaction, in stable Mission-ID order. They are nested under `repository_scope.missions`, not represented as members of the EP project: Forge persists a repository source for each Mission but no project ID. `project_mission_attribution` is therefore `UNAVAILABLE`, including after a same-repository project rebind. Each repository-scoped Mission includes its actual lifecycle, revision, presentation group and stored Action IDs/status/dependency edges. `APPROVED_PLANNABLE` is shown as `APPROVED_PENDING`; terminal `COMPLETED`/`ARCHIVED` are `HISTORY`; other recorded states remain visible in `ACTIVE`, including blocked and recoverable failures. These are display groups, not changed lifecycle states.
- The projection reports source freshness and marks `project_capability_graph` and `candidate_and_expected_views` `UNAVAILABLE`. It does not infer project capability edges, approvals, completion, time estimates or runtime concurrency from documentary DAGs. It makes no EP request and performs no planning, admission, Mission allocation, provider call or state mutation.

A missing binding yields `UNCONFIGURED` in the list and `503 PROJECT_UNCONFIGURED` for detail. A different project yields `404 PROJECT_MISSING`; malformed IDs yield `400 PROJECT_REFERENCE_INVALID`. Inconsistent installed identity or binding, unreadable storage and more than 256 Missions return an explicit `503`; inconsistent Mission identity or Action edges/cycles return `409`. No partial graph is represented as complete. Authentication failure returns `401`; unsupported methods return `405`. The exact route inventory is checked against OpenAPI and Postman in the repository validation suite.

This producer can be exercised from a fresh installed Forge wheel. Workspace may later consume it only through the authenticated HTTP contract and must not treat `repository_scope.missions` as project membership. Full project attribution, capability DAGs, Candidate/Expected views, cross-Mission edges, remote HTTPS, Workspace peer integration and `PRM-F-Q` remain under their owning dependencies and evidence gates. Forge runtime installation and public registry release are separate product operations.
6 changes: 6 additions & 0 deletions docs/roadmap/LIVE_PROJECT_ROADMAP_MANAGEMENT_V1.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ F5 is not a scheduler; its parent refinement is navigation, not a backward edge.
All nine nodes remain PLANNED with empty qualification evidence. External evidence
is a real producer subset, not permission to allocate peer implementation.

The bounded [project-bound Mission/Action read projection](../architecture/FORGE_PROJECT_DAG_READ_V1.md)
can deliver a useful HTTP producer subset before the full project-loop graph.
Its configured-project binding, repository-scoped installed Mission/Action edges
and explicit unavailable project membership and graph fields do not mark `PRM-F-PROJECTION`, its capability graph or `PRM-F-Q`
complete. Workspace peer consumption remains separately owned.

PRM-F-PROJECTION consumes F2/FH operation/HTTP subsets. PRM-F-ELIGIBILITY and
PRM-F-ACTIVATION consume the relevant existing intake, approval, progression,
claim and recovery services. PRM-F-CHANGES reuses the existing context/observer
Expand Down
26 changes: 26 additions & 0 deletions forge/api/server-openapi-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,32 @@
}
]
}
},
"/v1/projects": {
"get": {
"summary": "Read the configured project binding for this Forge instance",
"description": "Read-only. Returns an empty list with UNCONFIGURED when no current binding exists; never creates a Project.",
"responses": {
"200": {"description": "Configured project or explicit empty state"},
"401": {"description": "Authentication required"},
"503": {"description": "Installed source or binding is unavailable"}
}
}
},
"/v1/projects/{project_id}/roadmap": {
"get": {
"summary": "Read the persisted Mission and Action DAG subset for one configured project",
"description": "The response explicitly marks capability, Candidate, Expected and cross-Mission project graph views unavailable. No peer call or Mission mutation occurs.",
"parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$"}}],
"responses": {
"200": {"description": "Consistent installed Mission/Action snapshot"},
"400": {"description": "Invalid project reference"},
"401": {"description": "Authentication required"},
"404": {"description": "Project is not the configured project"},
"409": {"description": "Stored Mission/Action graph is inconsistent"},
"503": {"description": "Project binding or bounded projection is unavailable"}
}
}
}
},
"components": {
Expand Down
28 changes: 28 additions & 0 deletions forge/api/server-postman-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
{
"key": "missionId",
"value": "MISSION-0001"
},
{
"key": "projectId",
"value": "replace-with-configured-project-id"
}
],
"auth": {
Expand Down Expand Up @@ -493,6 +497,30 @@
]
}
}
},
{
"name": "List configured Forge project",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/v1/projects",
"host": ["{{baseUrl}}"],
"path": ["v1", "projects"]
}
}
},
{
"name": "Read configured project Mission and Action DAG",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/v1/projects/{{projectId}}/roadmap",
"host": ["{{baseUrl}}"],
"path": ["v1", "projects", "{{projectId}}", "roadmap"]
}
}
}
]
}
109 changes: 109 additions & 0 deletions forge/operations_read_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
API_VERSION = "1"
DEFAULT_STALE_AFTER = timedelta(minutes=5)
_MISSION_PATH = re.compile(r"^/v1/missions/([^/]+)$")
_PROJECT_ROADMAP_PATH = re.compile(r"^/v1/projects/([^/]+)/roadmap$")
_PROJECT_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,255}\Z")
_SENSITIVE_KEY = re.compile(r"(?:authorization|bearer|credential|password|secret|token)", re.IGNORECASE)
_BEARER_VALUE = re.compile(r"(?i)\bbearer\s+[^\s,;]+")
_KEYCHAIN_REFERENCE = re.compile(r"(?i)\bkeychain://[^\s,;]+")
Expand Down Expand Up @@ -263,6 +265,108 @@ def mission_detail(self, mission_id: str) -> dict[str, Any]:
"mission": projection,
})

def project_index(self) -> dict[str, Any]:
"""Expose only the project selected by this exact Forge instance binding."""
with self._runtime_snapshot() as (connection, metadata):
binding = self._project_binding(connection, metadata)
return {
"api_version": API_VERSION,
"contract_version": "project-roadmap-read/v1",
"instance_id": metadata["runtime_id"],
"availability": "AVAILABLE" if binding is not None else "UNCONFIGURED",
"projects": [] if binding is None else [{
"project_id": binding.ep_project_id,
"repository_id": binding.ep_repository_id,
"roadmap_url": f"/v1/projects/{binding.ep_project_id}/roadmap",
}],
"read_only": True,
}

def project_roadmap(self, project_id: str) -> dict[str, Any]:
"""Project the installed Mission/Action subset from one SQLite snapshot."""
if _PROJECT_ID.fullmatch(project_id) is None:
raise OperationsProjectionError("PROJECT_REFERENCE_INVALID", "Project reference is invalid", status=400)
with self._runtime_snapshot() as (connection, metadata):
binding = self._project_binding(connection, metadata)
if binding is None:
raise OperationsProjectionError("PROJECT_UNCONFIGURED", "Project binding is not configured", status=503)
if project_id != binding.ep_project_id:
raise OperationsProjectionError("PROJECT_MISSING", "Project was not found", status=404)
rows = connection.execute("SELECT mission_id FROM mission_state ORDER BY mission_id LIMIT 257").fetchall()
if len(rows) > 256:
raise OperationsProjectionError("PROJECT_TOO_LARGE", "Project projection exceeds the bounded read limit", status=503)
missions: list[dict[str, Any]] = []
for (mission_id,) in rows:
projection, state = mission_status_projection(connection, mission_id)
if state.mission_id != mission_id or state.mission.get("id") != mission_id:
raise OperationsProjectionError("PROJECT_AMBIGUOUS", "Mission identity is inconsistent", status=409)
source = state.mission.get("repository_evidence_source")
if not isinstance(source, Mapping) or source.get("repository_id") != binding.ep_repository_id:
raise OperationsProjectionError("PROJECT_AMBIGUOUS", "Mission repository binding is inconsistent", status=409)
actions = [_project_action(item) for item in state.actions]
ids = {item.get("id") for item in actions}
if len(ids) != len(actions) or None in ids or any(
dependency not in ids
for item in actions for dependency in item.get("dependencies", ())
):
raise OperationsProjectionError("PROJECT_DAG_INVALID", "Mission Action graph is inconsistent", status=409)
dependencies = {item["id"]: tuple(item.get("dependencies", ())) for item in actions}
active: set[str] = set()
complete: set[str] = set()

def visit(action_id: str) -> None:
if action_id in active:
raise OperationsProjectionError("PROJECT_DAG_INVALID", "Mission Action graph has a cycle", status=409)
if action_id in complete:
return
active.add(action_id)
for predecessor in dependencies[action_id]:
visit(predecessor)
active.remove(action_id)
complete.add(action_id)

for action_id in dependencies:
visit(action_id)
status = str(projection["status"])
group = ("APPROVED_PENDING" if status == "APPROVED_PLANNABLE" else
"HISTORY" if status in {"COMPLETED", "ARCHIVED"} else "ACTIVE")
missions.append({
"mission_id": mission_id, "group": group, "status": status,
"revision": projection["revision"],
"actions": [{key: item[key] for key in ("id", "status", "dependencies") if key in item}
for item in actions],
})
return {
"api_version": API_VERSION,
"contract_version": "project-roadmap-read/v1",
"instance_id": metadata["runtime_id"],
"project_id": project_id,
"repository_id": binding.ep_repository_id,
"availability": "AVAILABLE",
"freshness": _freshness(metadata.get("last_access_at"), now=self.clock(), stale_after=self.stale_after),
"graph_kind": "REPOSITORY_MISSION_ACTION_SUBSET",
"project_mission_attribution": "UNAVAILABLE",
"project_capability_graph": "UNAVAILABLE",
"candidate_and_expected_views": "UNAVAILABLE",
"repository_scope": {"repository_id": binding.ep_repository_id, "missions": missions},
"read_only": True,
}

def _project_binding(self, connection: sqlite3.Connection, metadata: Mapping[str, str]):
"""Join peer readback to the same storage generation as Mission state."""
readback = read_peer_configuration(self.root)
if readback.runtime_id != metadata.get("runtime_id") or readback.status not in {
"CONFIGURED", "NOT_CONFIGURED", "DETACHED",
}:
raise OperationsProjectionError("PROJECT_UNAVAILABLE", "Project binding is unavailable", status=503)
row = connection.execute(
"SELECT configuration_digest FROM execution_host_peer_configuration WHERE singleton=1"
).fetchone()
expected = readback.configuration.configuration_digest if readback.configuration is not None else None
if (row[0] if row is not None else None) != expected:
raise OperationsProjectionError("PROJECT_UNAVAILABLE", "Project binding changed during readback", status=503)
return readback.configuration

def installed_health_snapshot(self) -> dict[str, Any]:
"""Return the canonical bounded installed-health assessment."""
return InstalledHealthSnapshotService(
Expand Down Expand Up @@ -335,6 +439,11 @@ def handle(self, method: str, target: str, authorization: str | None) -> APIResp
if path == "/v1/health":
body = self.service.installed_health_snapshot()
return APIResponse(200 if body.get("outcome") == "HEALTHY" else 503, body, headers)
if path == "/v1/projects":
return APIResponse(200, self.service.project_index(), headers)
project_match = _PROJECT_ROADMAP_PATH.fullmatch(path)
if project_match:
return APIResponse(200, self.service.project_roadmap(unquote(project_match.group(1))), headers)
match = _MISSION_PATH.fullmatch(path)
if match:
return APIResponse(200, self.service.mission_detail(unquote(match.group(1))), headers)
Expand Down
4 changes: 4 additions & 0 deletions forge/server_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@
("GET", "/v1/instance"),
("GET", "/v1/version"),
("GET", "/v1/provider-context"),
("GET", "/v1/projects"),
("GET", "/v1/projects/{project_id}/roadmap"),
("POST", "/v1/provider-context"),
("GET", "/v1/execution-host/preflight"),
("POST", "/v1/execution-host/configure"),
Expand Down Expand Up @@ -522,6 +524,8 @@ def handle(self, method: str, target: str, authorization: str | None,
"code": "AUTHENTICATION_REQUIRED", "message": "Authentication is required",
}}, headers)
path = urlsplit(target).path
if path == "/v1/projects" or path.startswith("/v1/projects/"):
return self._read_api.handle(method, target, authorization)
if method == "GET" and (
path in {"/v1/status", "/v1/health"} or path.startswith("/v1/missions/")
and not path.endswith(("/controller/start", "/controller/reopen"))
Expand Down
2 changes: 1 addition & 1 deletion product-version.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"product": "forge",
"schema_version": 1,
"version": "2.7.39"
"version": "2.7.40"
}
Loading
Loading