Solver as a Service
Sign Up Now →Sign Up Now

Milestone 8: package, upload, and install the model

Preview

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.