Milestone 8: package, upload, and install the model
A manifest-driven solver needs a config-artifact installation contract. A developer should be able to build one versioned archive, upload it to the solver control plane, receive an admission report, activate the admitted version, discover its problem type, and execute it without deploying solver code.
The solver-pack artifact#
For the retail example, build an archive with this shape:
retail_scheduling-1.0.zip
├── pack.yaml
├── manifests/
│ └── retail_rota.yaml
├── schemas/
│ └── retail_rota.problem.schema.json
├── datasets/
│ └── small_store.demo.json
└── ui/
└── retail_rota.views.yaml
pack.yaml is the archive index. Its asset references are relative to the
archive root, so the same bundle works on a laptop, in CI, and in the hosted
solver:
id: retail_scheduling
version: "1.0"
display_name: "Retail Scheduling"
description: "Retail employee scheduling manifests and UX metadata"
modules: [solvers, ui]
manifests:
- manifests/retail_rota.yaml
solver_policies: [rostering]
ui_surfaces:
- ui/retail_rota.views.yaml
assets:
- role: problem_schema
path: schemas/retail_rota.problem.schema.json
- role: demo_dataset
path: datasets/small_store.demo.json
assets is a required extension to the existing ExtensionPack schema so the
admission service can validate every non-manifest file by role. It must be
added to the canonical pack model and schema; the current server ignores this
field and therefore cannot claim to admit the complete archive.
The archive is configuration, not an executable plugin. It may bind an
installed shared solver policy such as rostering; it may not contain Python,
JavaScript, native libraries, or a one-off optimizer. A genuinely new engine
capability is added to the shared engine and released separately, after which a
pack can select it declaratively.
Required upload and admission API#
The product contract should separate immutable upload/admission from mutable activation:
# Target contract — not implemented by the current solver server.
curl --fail-with-body \
-H "Authorization: Bearer ${SOLVER_TOKEN}" \
-F 'bundle=@retail_scheduling-1.0.zip;type=application/vnd.helixor.solver-pack+zip' \
"${SOLVER_URL}/api/v1/solver-packs"
Upload must unpack into quarantine, reject path traversal and links, enforce
size and file-type limits, compute a content digest, validate pack.yaml,
resolve every internal asset, validate every manifest and data schema, run the
solver registration gate, prove that every selected engine policy exists, and
persist only an immutable admitted version.
A successful admission response should be typed and complete:
{
"pack_id": "retail_scheduling",
"version": "1.0",
"digest": "sha256:...",
"status": "admitted",
"activatable": true,
"problem_types": ["retail_rota"],
"engine_policies": ["rostering"],
"artifacts": [
"manifests/retail_rota.yaml",
"schemas/retail_rota.problem.schema.json",
"datasets/small_store.demo.json",
"ui/retail_rota.views.yaml"
],
"findings": []
}
An invalid archive must return a typed 422 admission report and store no active
version. A (pack_id, version) collision with a different digest must return
409; versions are immutable rather than silently overwritten.
Activate the admitted version explicitly:
# Target contract — not implemented by the current solver server.
curl --fail-with-body \
-X PUT \
-H "Authorization: Bearer ${SOLVER_TOKEN}" \
-H 'Content-Type: application/json' \
--data '{"active": true, "expected_digest": "sha256:..."}' \
"${SOLVER_URL}/api/v1/solver-packs/retail_scheduling/versions/1.0/active"
Activation should atomically update the manifest catalog. The previous active version remains addressable for audit and rollback, and in-flight jobs retain the pack version and digest with which they started.
Only after activation does the normal runtime API take over:
curl --fail --silent \
-H "Authorization: Bearer ${SOLVER_TOKEN}" \
"${SOLVER_URL}/api/v1/manifests/retail_rota" | jq
curl --fail-with-body \
-H "Authorization: Bearer ${SOLVER_TOKEN}" \
-H 'Content-Type: application/json' \
--data @retail-problem.json \
"${SOLVER_URL}/api/v1/solve/retail_rota"
That is the central developer journey: build config artifacts, upload once, admit and activate a version, then solve as many explicit problem instances as needed.
The retail solver manifest#
The solver manifest inside the archive declares the custom problem type and the existing engine policy it reuses:
domain: RetailRostering
problem_type: retail_rota
policy_id: rostering
version: "1.0"
description: "Skills-aware scheduling for a small retailer"
ui:
hidden: false
surfaces:
solve:
enabled: true
primary: true
label: "Build a retail rota"
description: "Load employees and shifts, solve, and inspect coverage."
decision:
entities: {source: employees, identity: id}
groups: {source: shifts, identity: id}
domain: {total_over_entities: false, total_over_groups: true}
constraints:
skill_match:
name: "Required skill"
description: "An employee must hold the shift's required skill."
type: eligibility
default_enabled: true
contract: assigned_in_entity_set
parameters:
group_attribute: required_skill
entity_attribute: skills
one_shift_per_day:
name: "One shift per employee per day"
description: "An employee cannot cover two shifts on the same day."
type: eligibility
default_enabled: true
contract: entity_exclusive
parameters: {key_attribute: day, limit: 1}
min_coverage:
name: "Minimum coverage"
description: "Every shift must meet its own staffing requirement."
type: hard
default_enabled: true
contract: aggregate
parameters:
collect: count
compare: ">="
group_attribute: required_staff
max_hours:
name: "Maximum hours"
description: "Do not exceed each employee's period limit."
type: hard
default_enabled: true
contract: entity_load_max
parameters:
day_attribute: day
load_attribute: duration_hours
limit_attribute: max_hours
shift_preference:
name: "Shift preference"
description: "Prefer shifts the employee has marked acceptable."
type: soft
weight: 10.0
default_enabled: true
contract: assigned_in_entity_set
parameters:
group_attribute: id
entity_attribute: acceptable_shift_ids
Current server-local registration workaround#
The current implementation can register a descriptor only when every asset is already available to the server. For local development, an administrator can mount this layout:
/opt/helixor/packs/retail/ ├── retail_rota.yaml └── pack.yaml
The descriptor can point to that mounted file:
id: retail_scheduling version: "1.0" display_name: "Retail Scheduling" description: "Retail employee scheduling manifests and UX metadata" modules: [solvers, ui] manifests: - file:///opt/helixor/packs/retail/retail_rota.yaml solver_policies: [rostering]
Register the already-mounted descriptor through the instance API:
{
"pack": {
"id": "retail_scheduling",
"version": "1.0",
"display_name": "Retail Scheduling",
"description": "Retail employee scheduling manifests and UX metadata",
"modules": ["solvers", "ui"],
"manifests": [
"file:///opt/helixor/packs/retail/retail_rota.yaml"
],
"solver_policies": ["rostering"]
},
"enabled": true,
"source_uri": "file:///opt/helixor/packs/retail/pack.yaml"
}
curl --fail-with-body \
-H "Authorization: Bearer ${SOLVER_TOKEN}" \
-H 'Content-Type: application/json' \
--data @register-pack.json \
"${SOLVER_URL}/api/v1/extensions/register"
Then verify all three observable contracts:
# 1. The pack is registered and enabled.
curl --fail --silent \
-H "Authorization: Bearer ${SOLVER_TOKEN}" \
"${SOLVER_URL}/api/v1/extensions" | jq
# 2. Its problem type is discoverable.
curl --fail --silent \
-H "Authorization: Bearer ${SOLVER_TOKEN}" \
"${SOLVER_URL}/api/v1/manifests" \
| jq '.manifests[] | select(.problem_type == "retail_rota")'
# 3. The full manifest is available to the form renderer.
curl --fail --silent \
-H "Authorization: Bearer ${SOLVER_TOKEN}" \
"${SOLVER_URL}/api/v1/manifests/retail_rota" | jq
Do not stop at a 200 response from registration. The current resolver skips an
unresolvable or malformed pack manifest, so discovery is the required
post-registration check. This is a temporary administrator/deployment path,
not the developer upload experience. A file:// path on a developer laptop is
not resolvable by a remote solver server.