# Yukon service integration — staging

Implemented 5 October 2026; updated with the agreed manual pilot policy and one-way official-to-Yukon transfers. Production is unchanged.

Base URL: `https://ve-staging-api.aristoteleo.com`.
The website guide is at [API keys](https://ve-staging.pages.dev/challenge/account/api-keys#partner-service).

## Staging handoff and participant onboarding

The intended team is **EigenLab Yukon**, ID
`5d3cf0b8d68842c4b16eccc2d15a99ec`. The roster endpoint intentionally rejects
personal API keys with `A partner service credential is required.` Use the separate
read-only service credential for roster and result verification, and personal keys for uploads.

The existing service credential has been verified against this team. The organiser supplies
the secret and a current team invitation privately, separately from this document. Use a
recipient-restricted password-manager share or an equivalent encrypted secret-sharing channel
agreed with the named Yukon operator. Do not post either in a public thread, repository, issue,
or documentation page. Service credentials belong in Yukon's backend secret manager; members
should receive only the team invitation, not the service credential.

### Participant steps

1. Open [the staging account page](https://ve-staging.pages.dev/challenge/account).
   Sign in with GitHub or Google using a verified email you control. Use the same provider
   consistently; switching providers is not an account-linking mechanism.
2. Open the private **EigenLab Yukon staging invitation**. Check the team name, complete
   the participant details, personally read the current Rules and Terms and accept them,
   then join. Use the verified email of the signed-in account. Existing ordinary-team
   participants may transfer to Yukon. Once they join Yukon, returning to an ordinary
   team or creating one is prohibited, even after leaving Yukon.
   Joining through the invitation completes participant registration; creating a team is
   not a prerequisite.
3. Existing Yukon members can go directly to Account and accept any updated required
   agreements. Under **Team → Shared contest materials**, check the eligibility message.
   If email verification is missing, sign out and sign in again through GitHub or Google.
4. Sign in to Yukon and complete its GitHub connection. Yukon should match the unique,
   verified email on both sides and confirm `material_access.allowed=true` before admission.
   Missing or ambiguous matches require separate identity review; do not grant access merely
   because someone supplies a matching email address.
5. For uploads, open [staging API keys](https://ve-staging.pages.dev/challenge/account/api-keys)
   and create a **personal** key. Configure that key in Yukon's submission workflow. Obtain
   competition downloads directly through the VE account's Documents section.

**Production versus staging:** the environments have separate databases and sessions.
Participants can use the same existing GitHub/Google identity; they do not need to create a
new GitHub/Google account merely to use staging. A historical production database copy may
mean their VE account or membership already exists in staging, but subsequent changes do not
synchronize automatically. Each person must sign in to staging and check the actual team and
current agreement status there. A production membership or personal API key must not be
assumed to work in staging. Existing ordinary-team membership is eligible for a one-way transfer to Yukon. A prior
Yukon membership still blocks returning to ordinary teams; do not create another personal
account to bypass that restriction. Use designated staging test identities for integration QA.

### One-way transfer from an official team

Existing official-team participants can open the Yukon invitation without leaving their
current team first. The page shows a one-way transfer confirmation alongside the required
Rules and Terms acceptance. Confirming and submitting atomically leaves the current team
and joins Yukon; failed admission keeps the original membership, keys and history intact.
A captain with teammates must first transfer captaincy on Account → Team. Other members
may transfer directly. A sole member may transfer even if the old team has submissions:
the old team is archived and removed from subsequent rankings and prize eligibility, while
its scores, files and agreement records remain under the original team for historical audit.
An old invitation cannot reopen an archived team. A multi-member source team stays active
and retains its historical submissions.

The member's old API keys are revoked. Create a new personal Yukon key after joining.
Old scores and submission allowances are not copied into Yukon. Yukon still allows two
uploads per member per subtask per UTC day; leaving/rejoining Yukon never resets that
allowance. Once an identity has joined Yukon, it cannot create or join an ordinary team,
even after leaving, and using another account with the same recorded email does not bypass
this restriction. The transfer is recorded in the partner audit events.

For clients implementing this signed-in account workflow, `POST /challenge/teams/join`
accepts the existing invitation/participant/consent fields plus `transfer_from_team_id`,
which must exactly match the currently joined official team. Omit it for an ordinary fresh
join. A successful transfer adds `transfer: {from_team_id, from_team_name, archived}` to
the response. A missing/stale source confirmation, wrong transfer direction, or captain who
has not handed over returns 409. Neither a personal API key nor the partner service key can
perform this account action. This policy is deployed to staging; production is unchanged.

The captain can renew invitations using **Account → Team → Create an invitation link**.
Invitations expire after 14 days. Team service credentials cannot issue invitations.

### Designated test members and eligibility-change checks

Dedicated dummy identities are permitted **for this staging integration test only**. Keep
them separate from real participants, identify them with a `Yukon QA` display-name prefix,
and maintain a list of their stable member IDs. Use independently controlled, verified OAuth
email identities for end-to-end matching, with one VE account per test identity. This is not
an exception to the production one-person/one-account or participation-category rules.
There is no general email/password self-registration endpoint. The synthetic captain's
password login has no verified-email proof and cannot validate the email-matching workflow.

Testers perform agreement acceptance themselves. Use non-captain test members for leave and
removal tests, and coordinate captain/admin actions with VE. Do not remove the captain,
disqualify the whole team, or change global agreement versions just to run a test.

For each change, request a **new** roster snapshot: an existing cursor intentionally retains
the old immutable snapshot. During QA, immediate manual refreshes are supported within the
120-requests/minute limit; there is no need to wait a day to observe the change.

| Test | Supported action | Expected fresh-roster/result behavior |
| --- | --- | --- |
| Admission | Test member signs in and joins via invitation, personally accepting both agreements | `membership=active`, current consents, verified email, `material_access.allowed=true`; Yukon may grant access after matching. |
| Agreement withdrawal | Test member opens Account → Team → Shared contest materials → Manage required agreements and explicitly withdraws Rules or Terms | Membership stays active, consent becomes `withdrawn`, access is denied with `consent_withdrawn`, new uploads are blocked and unfinished entries are cancelled. |
| Reacceptance | Test member returns to Account and personally accepts the current agreement | Access becomes eligible if all other conditions hold; cancelled entries do not restart and quota is not refunded. |
| Voluntary departure | Non-captain test member selects Account → Team → Leave this team | Durable record remains as `left`, with `ended_at` and denied access; personal keys stop authorizing new uploads. |
| Captain removal | Captain removes only the designated non-captain test member | Durable record remains as `removed`, with denied access. |
| Rejoin | Same test identity accepts a valid Yukon invitation again | Same stable `member_id`; current consents are required; no allowance reset or revival of cancelled submissions. Create a new personal key if the previous key was revoked. |
| Individual disqualification | VE administrator disqualifies only the designated member with a QA reason; subsequently restores qualification | Access is denied with `member_disqualified`; unfinished entries cancel. Restoring qualification does not restart them. |
| Pending-result verification | Optionally use designated test predictions: leave after clearance, or leave while awaiting evidence | Cleared entries may finish after ordinary departure; awaiting-evidence entries cancel. Service verification retains the submission/member IDs and SHA-256. Consent withdrawal/disqualification cancel unfinished entries in either state. |
| Incomplete roster / outage | Simulate an HTTP error or interrupted traversal in Yukon's client/test transport | No new admission, no inferred departure from a partial/failed response; existing access follows the last verified status, with explicit removal notices still acted on. Never deliberately take VE offline. |

Record the member ID, snapshot ID/time, eligibility reason/effective time and resulting Yukon
grant/removal action for each case. Actual prediction uploads consume the test member's
normal allowance (two per subtask per UTC day); roster-only tests consume none. Do not use
real participants' submissions or credentials for these lifecycle tests. Production is unaffected.

### Credential expiry, rotation and first request

Use `Authorization: Bearer YOUR_PARTNER_SERVICE_CREDENTIAL` over HTTPS. Credentials expire
at the supplied `expires_at` Unix timestamp; the private handoff includes a UTC rendering.
The current staging handoff credential expires **2027-01-03 19:03:17 UTC** unless revoked earlier.
Rotate before expiry: ask VE to issue a replacement, install it in the Yukon backend, verify
a complete roster fetch, then have VE revoke the old credential. Expiry/revocation returns
401. A personal key is not a fallback for a failed service credential. Team ID is fixed by
the credential; no caller-supplied team parameter can expand access.

```bash
# Read the secret into a variable rather than pasting it into shell history.
read -r -s VE_PARTNER_SERVICE_KEY
export VE_PARTNER_SERVICE_KEY
curl --fail-with-body -sS \
  'https://ve-staging-api.aristoteleo.com/challenge/integration/partner/members?limit=100' \
  -H "Authorization: Bearer $VE_PARTNER_SERVICE_KEY"
unset VE_PARTNER_SERVICE_KEY
```

The complete response example, pagination checks, ended-member representation, rate limits
and manual-pilot operating policy follow below.

## Responsibilities and credentials

VE determines current membership, required-consent status and shared-material eligibility.
Yukon performs verified-email matching and applies/audits repository grants and removals.
Submission authorization remains separate: each member must register with VE, accept its
Rules and Terms personally, and authorize uploads through their own personal API key.
A service credential cannot upload, accept agreements or manage membership.

Organisers manage credentials under **Admin → EigenLab Yukon partner team → Service access
and member eligibility**. Create a credential, save its once-only secret in Yukon's backend
secret manager, and send `Authorization: Bearer <service credential>` on requests below.
Credentials are bound to one team, expire after at most 90 days, and carry
`partner:roster:read` and `partner:submissions:read`. They survive captain changes and
team exclusion so the service can reconcile access and audit historical submissions.
An organiser can revoke them independently. Rotate by creating a replacement, updating
Yukon, verifying it, then revoking the old credential. Never expose one to a browser or
include it in a URL. The database stores only its SHA-256 digest.

Each credential allows **120 requests per rolling 60 seconds**, shared across its endpoints.
429 responses include `Retry-After: 60`. Responses containing service data use `Cache-Control:
no-store`. Pagination is not a bulk email lookup service: all records belong to the configured
team and there is no public account-by-email endpoint.

## Individual member lookups

These endpoints use the **existing partner service credential**, its team scope and shared
120-request-per-minute allowance. They are backend-only integration endpoints; personal
API keys and ordinary browser sessions cannot use them. Admission and daily checks remain
manual under the pilot policy. A lookup does not grant access or authorize a submission.

- `GET /challenge/integration/partner/members/by-github/{github_user_id}` — initial match
  against the numeric GitHub ID returned by VE's GitHub OAuth sign-in.
- `GET /challenge/integration/partner/members/{member_id}` — current status for an already
  linked stable VE member ID, without downloading the roster.

Use the GitHub ID from Yukon's authenticated GitHub session, never a participant-entered
username, email or ID. Supply the ID as a decimal **string**, without leading zeroes
(1–20 digits, positive). VE member IDs have the form `vem_` followed by 32 lowercase hex
digits. Neither endpoint accepts a team override: the service credential determines scope.

```sh
curl --fail-with-body --silent --show-error \
  -H "Authorization: Bearer $VE_PARTNER_SERVICE_KEY" \
  "$VE_API_BASE/challenge/integration/partner/members/by-github/12345678"

curl --fail-with-body --silent --show-error \
  -H "Authorization: Bearer $VE_PARTNER_SERVICE_KEY" \
  "$VE_API_BASE/challenge/integration/partner/members/vem_0123456789abcdef0123456789abcdef"
```

Illustrative HTTP 200 response (example identifiers and timestamps):

```json
{
  "status": "matched",
  "team_id": "TEAM_ID",
  "checked_at": "2026-10-07T18:00:00+00:00",
  "member": {
    "team_id": "TEAM_ID",
    "member_id": "vem_0123456789abcdef0123456789abcdef",
    "github_user_id": "12345678",
    "membership": "active",
    "joined_at": "2026-10-01T12:00:00+00:00",
    "ended_at": null,
    "history_source": "join",
    "disqualified_at": null,
    "verified_email": "member@example.org",
    "email_verified_at": "2026-10-07T17:00:00+00:00",
    "email_verification_source": "github",
    "consent": {
      "rules": {"status": "current", "accepted_version": "VERSION", "required_version": "VERSION", "effective_at": "2026-10-01T12:00:00+00:00"},
      "terms": {"status": "current", "accepted_version": "VERSION", "required_version": "VERSION", "effective_at": "2026-10-01T12:00:00+00:00"}
    },
    "material_access": {"allowed": true, "reason": "eligible", "effective_at": "2026-10-01T12:00:00+00:00"}
  }
}
```

`member` uses the same eligibility calculation as the roster. `checked_at` is the current
UTC server check time, separate from `material_access.effective_at`, the time the policy
state took effect. Each individual lookup reads current state in a consistent transaction;
it does not reuse a cached roster snapshot. Successful matching is not proof of eligibility:
inspect `material_access.allowed`, membership and consent.

`github_user_id` is also included in newly generated roster snapshots. It is a string for
GitHub-authenticated member accounts and null for Google-only or synthetic accounts without
a numeric GitHub identity. It is never inferred by matching email. Google participants
continue through unique verified-email matching or manual review; we do not automatically
merge Google and GitHub accounts. Existing snapshots created before this addition retain
their original contents until expiry; restart pagination to see the new field.

Stable VE member IDs survive departure/rejoining, credential rotation and a GitHub user's
name/email changes. Former members still return **HTTP 200**, with their ended membership
and `material_access.allowed=false`. Withdrawal or disqualification likewise returns a
matched record with its access decision, not a fabricated “not found”.

| HTTP | Result | Handling |
| --- | --- | --- |
| 200 | `status: matched`, with `member` | Check eligibility before admission; store `(team_id, member_id)`. |
| 404 | `status: not_found` | No match in the authorized team. Users outside that team are indistinguishable from unknown users; this is not a global account lookup. |
| 409 | `status: ambiguous_match` | No identity selected or candidates disclosed; require manual review. |
| 422 | `status: invalid_lookup` | Fix the identifier format. |
| 503 | `status: temporarily_unavailable` | Storage temporarily unavailable; retry, never interpret as departure. |
| 401 / 403 | Authentication/authorization error (`detail`) | Check credential validity and scope; no personal-key fallback. |
| 429 | Rate limit (`detail`, `Retry-After: 60`) | Respect the shared service allowance and retry delay. |

The 404/409/422/503 responses include `checked_at` and an `error` object with `code` and
`message`; team ID is included when scope has been resolved (not for 503). They never
contain a member record. Example: `{"status":"not_found","checked_at":"UTC_TIMESTAMP",
"team_id":"TEAM_ID","error":{"code":"not_found","message":"No matching member in the authorized team."}}`.
Network errors, timeouts and any other 5xx/proxy response also mean the check **failed**;
they are not negative membership decisions. During outages, follow the existing manual
pilot policy: no new admissions, retain last verified existing permissions until checks
resume or an explicit VE removal notice is received. Escalate unexpected missing records
for previously linked members rather than assuming an API failure means removal.


## Member roster

`GET /challenge/integration/partner/members?limit=100`

`limit` defaults to 100 and ranges from 1 to 200. Follow the opaque `next_cursor`, URL-encoded
as a query parameter; never construct or modify it. Every page rechecks credential validity.

Illustrative shape (identifiers and timestamps below are examples):

```json
{
  "team_id": "TEAM_ID",
  "snapshot_id": "OPAQUE_SNAPSHOT_ID",
  "snapshot_at": "2026-10-05T19:00:00+00:00",
  "expires_at": 1791227700,
  "total_count": 1,
  "members": [{
    "team_id": "TEAM_ID",
    "member_id": "vem_OPAQUE_STABLE_ID",
    "github_user_id": "12345678",
    "membership": "active",
    "joined_at": "2026-10-01T12:00:00+00:00",
    "ended_at": null,
    "history_source": "join",
    "disqualified_at": null,
    "verified_email": "member@example.org",
    "email_verified_at": "2026-10-05T18:00:00+00:00",
    "email_verification_source": "github",
    "consent": {
      "rules": {"status": "current", "accepted_version": "VERSION", "required_version": "VERSION", "effective_at": "2026-10-01T12:00:00+00:00"},
      "terms": {"status": "current", "accepted_version": "VERSION", "required_version": "VERSION", "effective_at": "2026-10-01T12:00:00+00:00"}
    },
    "material_access": {"allowed": true, "reason": "eligible", "effective_at": "2026-10-01T12:00:00+00:00"}
  }],
  "next_cursor": null,
  "sync_policy": {
    "version": "yukon-manual-pilot-v1",
    "admission": "manual_after_current_eligibility_check",
    "poll_seconds": 86400,
    "normal_removal_seconds": 86400,
    "removal_deadline_basis": "confirmation_or_explicit_ve_notice",
    "remove_promptly": true,
    "outage_new_admissions": false,
    "outage_existing_access": "retain_last_verified_until_checks_resume_or_explicit_notice",
    "outage_grace_seconds": null,
    "prolonged_outage_action": "coordinate_manually_with_ve",
    "max_reconciliation_snapshot_age_seconds": 900,
    "submission_eligibility": "checked_on_every_request"
  }
}
```

`member_id` is team-scoped and stable across leaving/rejoining and key rotation. Bind by
`(team_id, member_id)`, not participant-row ID. `membership=active` represents current team
registration; other values are `left`, `removed`, and `ended_unknown` for incompletely
recoverable historical departures. Disqualification is separate from membership. Consent
statuses are `current`, `missing`, `outdated`, or `withdrawn`; communications preferences do
not affect access. Unknown historical times are null, never fabricated. `history_source`
distinguishes `join` from migration-derived records.

`material_access` is VE's policy decision, independent of whether Yukon has successfully
linked the user's identity. Denial precedence: `team_disqualified`, `member_disqualified`,
inactive membership (`left`, `removed`, `ended_unknown`) or `team_disbanded`,
`consent_withdrawn`, then `consent_required`. Current consent and active eligible membership
produce `eligible`. The effective time comes from the underlying membership, consent,
qualification or document-version event, rather than each request's timestamp.

**Email matching:** match only an exact, unique verified address from each system, after
trimming and case normalization. Do not strip Gmail dots, rewrite aliases, or merge VE
accounts. `verified_email=null` means no recorded verification proof; it does not mean the
user has no account. Previously registered users may need to sign in again through OAuth.
Synthetic test accounts do not have verified email proof. Unmatched or ambiguous users
must remain unlinked; use separate manual review. An automated account-linking handoff
and webhooks are not part of this release.

### Reconciliation and outages

1. Start a new traversal without a cursor. The server materializes an immutable snapshot,
   including former members, under a consistent database transaction.
2. Retrieve all pages. Require the same snapshot ID, timestamp and count; require unique
   member IDs, and require the final number of records to equal `total_count`.
3. Only reconcile after `next_cursor=null`. An HTTP failure, missing page or partial list
   is never evidence of departure. A credential cannot read another credential's cursor.
4. Snapshots expire after 15 minutes; HTTP 410 means discard the partial result and restart.
   Finish within 15 minutes of `snapshot_at`. If a manual admission is performed later,
   fetch a fresh, complete snapshot before granting access; a daily cached roster is not
   sufficient for a new admission.

### Initial pilot operating policy

- **Manual admission:** verify VE registration, active team membership, current acceptance
  of both required agreements and the identity match before granting access. Use
  `material_access.allowed` as VE's eligibility decision. An eligible record alone does
  not establish the identity match.
- **Daily checks:** check current eligibility at least once every 24 hours. Automation is
  optional for this pilot. Record the check time and snapshot ID, and record admission and
  removal actions with the stable member ID and reason.
- **Prompt removal:** remove access as soon as a check confirms ineligibility, and no later
  than 24 hours after confirmation or an explicit VE removal notice, whichever comes first.
  This includes departure/removal, required consent becoming non-current (including
  withdrawal), and member/team disqualification. The 24 hours is an upper bound, not a
  recommended wait. Daily detection plus the maximum action window can approach 48 hours
  after an unnotified change; this is not a promise of removal within 24 hours of departure.
- **API outage:** admit no new participants. Existing hosted-material permissions may remain
  based on the last verified status until checks resume. Explicit VE removal notices still
  require action within the same deadline; do not wait for API recovery. There is no automatic
  30-minute suspension or automatic outage deadline. Coordinate prolonged outages manually
  with VE. After recovery, fetch and reconcile a fresh, complete snapshot before resuming
  admissions. Errors and partial responses neither confirm removal nor refresh verification.
- **Separate submission enforcement:** these operating windows apply to Yukon's hosted
  materials only. Every personal-key submission still checks current membership and required
  consent at VE; a still-valid key or cached eligibility does not permit an ineligible upload.

`sync_policy.version=yukon-manual-pilot-v1` identifies this contract. `poll_seconds=86400`
is the maximum interval between eligibility checks, including manual checks.
`normal_removal_seconds=86400` runs from confirmation or an explicit VE notice (not from
`snapshot_at` or the original departure). `outage_grace_seconds=null` means no automatic
outage-triggered suspension; it does not mean zero seconds or permission to ignore explicit
notices. The 900-second snapshot freshness limit applies to each traversal/admission, not
to how often checks must run. Yukon applies these permissions; VE does not operate or
remotely revoke access to Yukon's GitHub repositories.

An explicit denied record requires Yukon to remove that member's hosted access under the deadline above. After a complete verified
snapshot, an identity previously linked to this team but absent from the entire snapshot
must be treated as ineligible under the same removal policy. Revocation stops future access
to hosted shared materials, including downloads and updates. Local Git clones and previously downloaded files cannot be
remotely erased. VE's restriction on switching from Yukon to ordinary competition teams
continues after departure.

## Submission verification

- `GET /challenge/integration/partner/submissions?limit=100`
- `GET /challenge/integration/partner/submissions/{id}`

The list keeps its existing envelope: `team_id`, `partner_kind`, `shows_scores`,
`prize_eligible=false`, `submissions`, `next_cursor`. The list bounds the set of submissions
at traversal start, but scores remain live; start another traversal to discover new entries
and refreshed outcomes. This differs intentionally from the immutable membership snapshot.
Service result cursors expire after 15 minutes; an invalid/expired cursor returns 400.
The old captain-session/personal-key export remains supported with its existing cursor format.

Records include `id`, `member_id`, `sha256`, task/setting/mode/subtask, bundle/phase/split,
model/architecture, `status`, `reject_reason`, headline/metrics and created/scored timestamps.
New lifecycle fields:

| Field | Meaning |
| --- | --- |
| `cleared_for_scoring` | The entry passed its initial scoring gate at some point; does not override subsequent cancellation. |
| `cleared_at` | Exact clearance timestamp, or null when a historical timestamp cannot be recovered. |
| `clearance_source` | `upload`, `evidence`, `legacy_scoring_state`, `legacy_queued_active`, `legacy_unknown`, or null. |
| `cancelled_at`, `cancellation_reason` | Permanent termination and its cause; quota is not refunded. |
| `result_valid` | True only for a completed scored record that has not been withdrawn, cancelled or invalidated by member/team disqualification. |
| `invalid_reason` | `team_disqualified`, `member_disqualified`, `withdrawn`, a cancellation reason, or null. Null alone does not imply a completed valid score. |

Verify the tuple `(id, member_id, sha256)` against the Yukon entry. The digest is the
server-computed SHA-256 of exact uploaded bytes and remains available after re-scoring or
file archival. Only use scores when `status=scored`, `result_valid=true`, and the list's
`shows_scores=true`. The single-record endpoint returns null headline/metrics whenever
phase visibility disallows them. Cross-team or nonexistent submission IDs return 404.

Members may withdraw their own individual entries through the website; they cannot withdraw another member's entries or withdraw the entire team. The captain retains team-wide withdrawal authority. An unfinished
withdrawn entry becomes cancelled, and a completed entry becomes invalid without deleting its score.

### Lifecycle policy

| Change | Already cleared, unfinished | Awaiting evidence | Completed |
| --- | --- | --- | --- |
| Ordinary departure/removal | Continues | Permanently cancelled | Retained |
| Required consent withdrawn | Cancelled | Cancelled | Retained for audit; existing validity unchanged |
| Member/team disqualified | Cancelled | Cancelled | Retained, invalid and excluded from effective public results |
| Rules/Terms version becomes outdated | May finish | Cannot supply evidence until current documents are accepted | Retained |
| Qualification restored or member rejoins | Cancelled entries do not restart | Cancelled entries do not restart | Disqualification invalidity can clear; an explicitly withdrawn entry stays withdrawn |

`cancelled` is terminal. `eligibility_review` holds historical unfinished entries whose
eligibility cannot be established; organisers must review before any future rescore.
Evidence acceptance and promotion are atomic with eligibility checks. Late Modal results
cannot overwrite cancellation. VE attempts to cancel already-started Modal calls; if that
operation fails, the result remains suppressed. Temporary remote cancellation failures do
not restore eligibility.

Service verification remains available after departure, consent withdrawal and disqualification.
It does not restore a former member's personal access. Personal submissions still enforce
current membership, current Rules/Terms, agent requirements and existing quota policy.

## User and administrator controls

Partner members can review eligibility and withdraw either required agreement under
**Account → Team → Shared contest materials → Manage required agreements**. Withdrawal
requires a normal signed-in session and explicit confirmation; API keys cannot perform it.
Accepting the current agreement again uses the existing Account document workflow, records
a new event, and does not revive cancelled submissions or refund attempts.

Administrators can issue/revoke credentials and disqualify/restore individual members using
the new management panel. Each qualification change requires a reason. Existing team-level
exclusion also cancels unfinished partner entries. Credentials and qualification operations
are audited separately from member actions.

Administrative routes (normal administrator session only):

- `POST /challenge/admin/partner/service-keys`: `{ "team_id": "...", "name": "...", "days": 90 }`; once-only token response.
- `GET /challenge/admin/partner/service-keys`: metadata only, never secrets/digests.
- `POST /challenge/admin/partner/service-keys/{id}/revoke`.
- `GET /challenge/admin/partner/members`.
- `POST /challenge/admin/partner/members/{member_id}/qualification`: `{ "disqualified": true, "reason": "..." }` (false restores).

## Example requests

```bash
# Load VE_PARTNER_SERVICE_KEY from your backend secret manager.
export VE_API_BASE='https://ve-staging-api.aristoteleo.com'
curl --fail-with-body -sS "$VE_API_BASE/challenge/integration/partner/members?limit=100" \
  -H "Authorization: Bearer $VE_PARTNER_SERVICE_KEY"
curl --fail-with-body -sS --get "$VE_API_BASE/challenge/integration/partner/members" \
  --data-urlencode "cursor=$NEXT_CURSOR" \
  -H "Authorization: Bearer $VE_PARTNER_SERVICE_KEY"
curl --fail-with-body -sS "$VE_API_BASE/challenge/integration/partner/submissions/$VE_SUBMISSION_ID" \
  -H "Authorization: Bearer $VE_PARTNER_SERVICE_KEY"
```

401: invalid/missing/expired/revoked credential. 403: wrong credential type or unavailable
partner integration. 404: submission not in the credential's team. 410: invalid/expired
roster cursor. 422: invalid query/body. 429: rate limit. Retry transient failures with
backoff; discard partial traversals when necessary. Do not interpret errors as empty rosters.

## Validation and deployment

- 59 backend tests passed, including ordinary-team API/quota regressions, 12 service/lifecycle
  tests and six one-way-transfer tests covering reverse-direction rejection, archive ranking,
  atomic rollback, captain handover, key revocation and concurrent transfer attempts.
- Migration rehearsal on a private staging database copy preserved every original row and
  column in all 19 existing tables. New schema is additive.
- Nuxt build passed; deployed at `https://6c3226be.ve-staging.pages.dev` and staging alias.
- Live HTTPS checks confirmed roster/export/detail access, account-route rejection, foreign
  submission protection, and the original Matthew artifact's unchanged SHA-256 and 50.8666
  headline. No prediction was uploaded and no submission quota spent during these checks.
- Chrome verified the deployed API guide and the synthetic captain's shared-materials panel.
  No agreement was accepted or withdrawn during this UI check.
- Backup: `/srv/ve-deploy/backups/partner-access-20261005T190145Z/`.
- Pre-feature image: `deploy-api:before-partner-access-20261005T190145Z`.
- Manual pilot policy deployment backup: `/srv/ve-deploy/backups/partner-access-20261005T195659Z/` (includes the prior service integration release).
- Live roster policy is `yukon-manual-pilot-v1`; Chrome verified the updated staging guide. The existing read-only service credential remains valid. No new credential is required.

Reviewed overlays and reports live in
`virtualembryo-backend/deploy/eigenlab-staging/partner-access/`.
The frontend overlay is reproducible against Git commit `63db3c9` and includes prior staging
API/partner customizations. The backend overlay requires the captured live staging hashes.
Do not deploy the older repository checkout wholesale.

Before rollback, disable/revoke service integration and pause partner scoring; an older
worker cannot safely interpret new cancellation states. Do not overwrite a live database
with the backup after new activity. Preserve events, cancellations and post-deployment data.

## Direct data downloads through a personal API key

Yukon may show VE-hosted download links in its interface to an eligible participant.
Files download directly from VE's private object storage. This integration does not
authorize Yukon to redistribute or host copies of the datasets.

1. The participant registers or joins a team in VE and accepts the current Rules and
   Terms of Use on the VE account page.
2. At **Submissions → API keys**, create a personal key with **Allow data downloads**
   checked. This adds the explicit `data:read` scope. Existing keys and default
   API-created keys are not silently upgraded; create a replacement key if needed.
3. Store that key in Yukon's backend secret storage. Request the manifest, then request
   a link for an exact `key` returned in that manifest when the participant clicks Download.
4. Give the returned `url` to that participant's browser as a normal link/navigation.
   It triggers a download without a VE browser session or an Authorization header to
   storage. Do not forward the personal API key to storage, log signed URLs, put them
   into analytics, or share/cache one participant's URL for other users.

Both endpoints accept `Authorization: Bearer PERSONAL_API_KEY` with `data:read`.
The read-only **partner service credential cannot call them**: eligibility and required
agreement acceptance are checked for the individual on every request. The existing VE
website session can also call these same endpoints.

```bash
export VE_API_BASE="https://ve-staging-api.aristoteleo.com"
# Read VE_CHALLENGE_API_KEY from your secret store.

curl --fail-with-body -sS "$VE_API_BASE/challenge/data/manifest" \
  -H "Authorization: Bearer $VE_CHALLENGE_API_KEY"

# Select an exact file key from the manifest.
curl --fail-with-body -sS --get "$VE_API_BASE/challenge/data/link" \
  -H "Authorization: Bearer $VE_CHALLENGE_API_KEY" \
  --data-urlencode "key=T2/E6.75.h5ad"
```

Manifest response (abbreviated; other released files omitted):
```json
{
  "files": [
    {"key": "T2/E6.75.h5ad", "task": "T2", "role": "train (embryo)",
     "stage": "E6.75", "modality": "MERFISH", "bytes": 17454701}
  ],
  "total_bytes": 2259704306,
  "link_ttl_seconds": 900,
  "note": "Training inputs only. Validation and test targets are not distributed — a submission comes back as a score."
}
```

Link response (illustrative URL and time):
```json
{
  "key": "T2/E6.75.h5ad",
  "url": "https://VE_STORAGE_HOST/BUCKET/OBJECT?SIGNED_QUERY",
  "expires_in": 900,
  "expires_at": "2026-10-10T01:15:00+00:00",
  "task": "T2",
  "role": "train (embryo)",
  "stage": "E6.75",
  "modality": "MERFISH",
  "bytes": 17454701
}
```

Responses use `Cache-Control: no-store`. Links last at most 15 minutes; use the returned
`expires_in`/`expires_at`, as storage-credential expiry can shorten that interval.
Use the URL exactly as returned. A browser link is sufficient; cross-origin JavaScript
fetching of file contents is not required or promised. Request a fresh link after expiry.

Leaving/removal, disqualification, or withdrawal/staleness of required agreement
acceptance blocks **new** link issuance. Already issued links may still work until
their short expiry; previously downloaded local files cannot be remotely deleted.
Only allowlisted, released training inputs are accessible. No validation/test targets,
arbitrary object keys, or unreleased data are exposed. Link issuance is audited and
does **not** spend submission or scoring allowance.

| Status | Meaning / action |
| --- | --- |
| 401 | Invalid, revoked, expired, or no-longer-applicable personal credential. |
| 403 | Missing `data:read`, no active eligible team membership, or disqualification. |
| 404 | File key is not in the released manifest. |
| 409 | Current Rules/Terms acceptance is required; return to the VE account page. |
| 503 | Data release is closed or storage configuration/credentials need attention. Retry later; contact VE if persistent. |

This change is available on **staging only**. Production rollout remains separate.
